From f8c0d2ed5f995b75e3650b0ed9c8e219bbfd97c4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 04:07:57 -0400 Subject: [PATCH 01/12] add libdc_parse_raw_dive() for standalone dive data parsing Extract field extraction logic from parse_dive() into a shared extract_dive_fields() function, then add libdc_parse_raw_dive() which uses dc_parser_new2() to parse raw binary dive data without requiring a device connection. This enables parsing Shearwater Cloud database exports offline. --- .../macos/Classes/libdc_download.c | 153 +++++++++++++----- .../macos/Classes/libdc_wrapper.h | 14 ++ 2 files changed, 129 insertions(+), 38 deletions(-) diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c index 22daa10bb..18605f8b9 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c @@ -4,6 +4,7 @@ #include "libdc_wrapper.h" #include #include +#include #include #include @@ -229,36 +230,10 @@ static void sample_callback(dc_sample_type_t type, } } -static int parse_dive(download_state_t *state, - const unsigned char *data, unsigned int size, - const unsigned char *fingerprint, unsigned int fsize, - libdc_parsed_dive_t *dive) { - memset(dive, 0, sizeof(*dive)); - dive->min_temp = NAN; - dive->max_temp = NAN; - dive->deco_model_type = 0; // DC_DECOMODEL_NONE - dive->deco_conservatism = 0; - dive->gf_low = 0; - dive->gf_high = 0; - dive->events = NULL; - dive->event_count = 0; - dive->event_capacity = 0; - - // Store fingerprint. - if (fingerprint != NULL && fsize > 0) { - unsigned int copy_size = fsize < LIBDC_MAX_FINGERPRINT ? - fsize : LIBDC_MAX_FINGERPRINT; - memcpy(dive->fingerprint, fingerprint, copy_size); - dive->fingerprint_size = copy_size; - } - - // Create parser. - dc_parser_t *parser = NULL; - dc_status_t status = dc_parser_new(&parser, state->device, data, size); - if (status != DC_STATUS_SUCCESS || parser == NULL) { - return -1; - } - +// Extract all fields (datetime, summary, deco model, gas mixes, tanks, samples) +// from an already-created parser into the dive struct. +// Returns 0 on success. +static int extract_dive_fields(dc_parser_t *parser, libdc_parsed_dive_t *dive) { // Extract datetime. dc_datetime_t dt = {0}; if (dc_parser_get_datetime(parser, &dt) == DC_STATUS_SUCCESS) { @@ -297,7 +272,7 @@ static int parse_dive(download_state_t *state, // Extract decompression model. dc_decomodel_t decomodel = {0}; if (dc_parser_get_field(parser, DC_FIELD_DECOMODEL, 0, &decomodel) == DC_STATUS_SUCCESS) { - dive->deco_model_type = decomodel.type; // DC_DECOMODEL_NONE=0, BUHLMANN=1, VPM=2, RGBM=3, DCIEM=4 + dive->deco_model_type = decomodel.type; dive->deco_conservatism = decomodel.conservatism; dive->gf_low = decomodel.params.gf.low; dive->gf_high = decomodel.params.gf.high; @@ -306,9 +281,7 @@ static int parse_dive(download_state_t *state, // Extract gas mixes. unsigned int gasmix_count = 0; if (dc_parser_get_field(parser, DC_FIELD_GASMIX_COUNT, 0, &gasmix_count) == DC_STATUS_SUCCESS) { - if (gasmix_count > LIBDC_MAX_GASMIXES) { - gasmix_count = LIBDC_MAX_GASMIXES; - } + if (gasmix_count > LIBDC_MAX_GASMIXES) gasmix_count = LIBDC_MAX_GASMIXES; for (unsigned int i = 0; i < gasmix_count; i++) { dc_gasmix_t gm = {0}; if (dc_parser_get_field(parser, DC_FIELD_GASMIX, i, &gm) == DC_STATUS_SUCCESS) { @@ -322,9 +295,7 @@ static int parse_dive(download_state_t *state, // Extract tanks. unsigned int tank_count = 0; if (dc_parser_get_field(parser, DC_FIELD_TANK_COUNT, 0, &tank_count) == DC_STATUS_SUCCESS) { - if (tank_count > LIBDC_MAX_TANKS) { - tank_count = LIBDC_MAX_TANKS; - } + if (tank_count > LIBDC_MAX_TANKS) tank_count = LIBDC_MAX_TANKS; for (unsigned int i = 0; i < tank_count; i++) { dc_tank_t tk = {0}; if (dc_parser_get_field(parser, DC_FIELD_TANK, i, &tk) == DC_STATUS_SUCCESS) { @@ -344,10 +315,45 @@ static int parse_dive(download_state_t *state, dc_parser_samples_foreach(parser, sample_callback, &sample_state); push_sample(&sample_state); - dc_parser_destroy(parser); return 0; } +static int parse_dive(download_state_t *state, + const unsigned char *data, unsigned int size, + const unsigned char *fingerprint, unsigned int fsize, + libdc_parsed_dive_t *dive) { + memset(dive, 0, sizeof(*dive)); + dive->min_temp = NAN; + dive->max_temp = NAN; + dive->deco_model_type = 0; // DC_DECOMODEL_NONE + dive->deco_conservatism = 0; + dive->gf_low = 0; + dive->gf_high = 0; + dive->events = NULL; + dive->event_count = 0; + dive->event_capacity = 0; + + // Store fingerprint. + if (fingerprint != NULL && fsize > 0) { + unsigned int copy_size = fsize < LIBDC_MAX_FINGERPRINT ? + fsize : LIBDC_MAX_FINGERPRINT; + memcpy(dive->fingerprint, fingerprint, copy_size); + dive->fingerprint_size = copy_size; + } + + // Create parser. + dc_parser_t *parser = NULL; + dc_status_t status = dc_parser_new(&parser, state->device, data, size); + if (status != DC_STATUS_SUCCESS || parser == NULL) { + return -1; + } + + int result = extract_dive_fields(parser, dive); + + dc_parser_destroy(parser); + return result; +} + static int dive_callback(const unsigned char *data, unsigned int size, const unsigned char *fingerprint, unsigned int fsize, void *userdata) { @@ -614,3 +620,74 @@ int libdc_download_run( return result; } + +// ============================================================ +// Standalone Raw Dive Parsing +// ============================================================ + +int libdc_parse_raw_dive( + const char *vendor, const char *product, unsigned int model, + const unsigned char *data, unsigned int size, + libdc_parsed_dive_t *result, + char *error_buf, size_t error_buf_size) +{ + if (vendor == NULL || product == NULL || data == NULL || + size == 0 || result == NULL) { + if (error_buf && error_buf_size > 0) { + strncpy(error_buf, "Invalid arguments", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + return LIBDC_STATUS_INVALIDARGS; + } + + memset(result, 0, sizeof(*result)); + result->min_temp = NAN; + result->max_temp = NAN; + result->events = NULL; + result->event_count = 0; + result->event_capacity = 0; + + dc_context_t *context = NULL; + dc_status_t status = dc_context_new(&context); + if (status != DC_STATUS_SUCCESS) { + if (error_buf && error_buf_size > 0) { + strncpy(error_buf, "Failed to create context", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + return (int)status; + } + + dc_descriptor_t *descriptor = find_descriptor(vendor, product, model); + if (descriptor == NULL) { + dc_context_free(context); + if (error_buf && error_buf_size > 0) + snprintf(error_buf, error_buf_size, + "No descriptor for %s %s (model %u)", + vendor, product, model); + return LIBDC_STATUS_NODEVICE; + } + + dc_parser_t *parser = NULL; + status = dc_parser_new2(&parser, context, descriptor, data, size); + if (status != DC_STATUS_SUCCESS || parser == NULL) { + dc_descriptor_free(descriptor); + dc_context_free(context); + if (error_buf && error_buf_size > 0) + snprintf(error_buf, error_buf_size, + "Parser creation failed (status %d)", (int)status); + return (int)status; + } + + int parse_result = extract_dive_fields(parser, result); + + dc_parser_destroy(parser); + dc_descriptor_free(descriptor); + dc_context_free(context); + + if (parse_result != 0 && error_buf && error_buf_size > 0) { + strncpy(error_buf, "Field extraction failed", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + + return parse_result; +} diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h index f1a54e4b7..6367302b7 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h @@ -246,4 +246,18 @@ void libdc_download_cancel(libdc_download_session_t *session); // Free the session. void libdc_download_session_free(libdc_download_session_t *session); +// ============================================================ +// Standalone Raw Dive Parsing +// ============================================================ + +/// Parse raw dive computer binary data without a device connection. +/// Uses dc_parser_new2() with a descriptor looked up by vendor/product/model. +/// Returns 0 on success, negative on failure. +/// The caller must free result->samples and result->events when done. +int libdc_parse_raw_dive( + const char *vendor, const char *product, unsigned int model, + const unsigned char *data, unsigned int size, + libdc_parsed_dive_t *result, + char *error_buf, size_t error_buf_size); + #endif From 0524faa654d3010570bf949019e00232cde59482 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 04:19:29 -0400 Subject: [PATCH 02/12] add parseRawDiveData Pigeon API method with platform bridges Expose libdc_parse_raw_dive() to Dart through Pigeon so the Shearwater Cloud import pipeline can parse raw dive data without a live device connection. Full implementation on Darwin (macOS/iOS); Android, Windows, and Linux return UNSUPPORTED stubs for now. --- .../data/services/shearwater_dive_mapper.dart | 356 ++++++++++++ .../libdivecomputer/DiveComputerApi.g.kt | 24 + .../DiveComputerHostApiImpl.kt | 15 + .../LibDCDarwin/DiveComputerHostApiImpl.swift | 51 ++ .../ios/Classes/DiveComputerApi.g.swift | 21 + .../src/generated/dive_computer_api.g.dart | 35 ++ .../linux/dive_computer_api.g.cc | 81 +++ .../linux/dive_computer_api.g.h | 21 + .../linux/dive_computer_host_api_impl.cc | 14 + .../macos/Classes/DiveComputerApi.g.swift | 21 + .../pigeons/dive_computer_api.dart | 8 + .../windows/dive_computer_api.g.cc | 47 ++ .../windows/dive_computer_api.g.h | 6 + .../windows/dive_computer_host_api_impl.cc | 10 + .../windows/dive_computer_host_api_impl.h | 7 + .../services/shearwater_dive_mapper_test.dart | 513 ++++++++++++++++++ 16 files changed, 1230 insertions(+) create mode 100644 lib/features/universal_import/data/services/shearwater_dive_mapper.dart create mode 100644 test/features/universal_import/data/services/shearwater_dive_mapper_test.dart diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart new file mode 100644 index 000000000..e5d6eab4f --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -0,0 +1,356 @@ +import 'package:flutter/services.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_filename_parser.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_value_mapper.dart'; + +/// Converts [ShearwaterRawDive] objects into `Map` entity maps +/// matching the field conventions used by the existing import system +/// ([UddfEntityImporter] / [IncomingDiveData.fromImportMap]). +class ShearwaterDiveMapper { + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Produces a dive entity map from metadata only (no profile data). + /// + /// This is the fallback when FFI parsing fails or binary log data is absent. + static Map mapDiveMetadata(ShearwaterRawDive rawDive) { + final isImperial = _isImperial(rawDive); + final filenameInfo = rawDive.fileName != null + ? ShearwaterFilenameParser.parse(rawDive.fileName!) + : const ShearwaterFilenameInfo(); + + final tanks = mapTanks(rawDive); + final surfacePressure = _extractSurfacePressure(rawDive); + + return { + 'importSource': 'shearwater_cloud', + 'importId': rawDive.diveId, + 'dateTime': _parseDateTime(rawDive.diveDate), + 'maxDepth': rawDive.depth, + 'avgDepth': rawDive.averageDepth, + 'runtime': rawDive.diveLengthTime != null + ? Duration(seconds: rawDive.diveLengthTime!) + : null, + 'diveNumber': _parseInt(rawDive.diveNumber), + if (rawDive.buddy != null) 'buddyRefs': [rawDive.buddy!], + 'notes': _buildNotes(rawDive), + 'siteName': rawDive.site, + if (rawDive.site != null) + 'site': {'uddfId': rawDive.site, 'name': rawDive.site}, + 'diveComputerModel': filenameInfo.model, + 'diveComputerSerial': filenameInfo.serial, + 'waterType': ShearwaterValueMapper.mapWaterType(rawDive.environment), + 'visibility': ShearwaterValueMapper.mapVisibility( + rawDive.visibility, + isImperial: isImperial, + ), + 'cloudCover': ShearwaterValueMapper.mapCloudCover(rawDive.weather), + 'currentStrength': ShearwaterValueMapper.mapCurrentStrength( + rawDive.conditions, + ), + 'airTemp': _convertTemperature(rawDive.airTemperature, isImperial), + 'weightAmount': _convertWeight(rawDive.weight, isImperial), + 'surfacePressure': surfacePressure, + 'diveMode': _mapDiveMode(rawDive.apparatus), + 'tanks': tanks, + 'profile': const >[], + }; + } + + /// Attempts FFI parsing for profile data, falls back to metadata-only. + /// + /// If [rawDive.decompressedLogData] is available, tries to call + /// [DiveComputerHostApi().parseRawDiveData()] to get rich profile data. + /// On failure (including [MissingPluginException] in test environments), + /// adds a warning and returns the metadata-only map. + static Future> mapDive( + ShearwaterRawDive rawDive, { + List? warnings, + }) async { + final baseMap = mapDiveMetadata(rawDive); + + final logData = rawDive.decompressedLogData; + if (logData == null || logData.isEmpty) { + return baseMap; + } + + final filenameInfo = rawDive.fileName != null + ? ShearwaterFilenameParser.parse(rawDive.fileName!) + : const ShearwaterFilenameInfo(); + + final vendorProduct = filenameInfo.model != null + ? ShearwaterFilenameParser.vendorProduct(filenameInfo.model!) + : null; + + if (vendorProduct == null) { + warnings?.add( + const ImportWarning( + severity: ImportWarningSeverity.warning, + message: + 'Could not determine dive computer model for profile parsing', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; + } + + try { + final parsed = await _parseWithFfi( + vendor: vendorProduct.$1, + product: vendorProduct.$2, + data: logData, + ); + return _mergeWithParsedDive(baseMap, parsed); + } catch (_) { + warnings?.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: + 'Profile parsing failed for dive ${rawDive.diveId}; ' + 'using metadata only', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; + } + } + + /// Parse [TankProfileData] JSON into tank entity maps. + /// + /// Only active tanks (where `DiveTransmitter.IsOn == true`) are included. + /// Pressures are converted from PSI to bar (rounded to int). + static List> mapTanks(ShearwaterRawDive rawDive) { + final tankData = + rawDive.tankProfileData?['TankData'] as List? ?? []; + if (tankData.isEmpty) return const []; + + final results = >[]; + + for (final raw in tankData) { + final tank = raw as Map; + final transmitter = tank['DiveTransmitter'] as Map?; + + if (transmitter == null || transmitter['IsOn'] != true) continue; + + final gasProfile = tank['GasProfile'] as Map?; + final o2 = _toDouble(gasProfile?['O2Percent']) ?? 21.0; + final he = _toDouble(gasProfile?['HePercent']) ?? 0.0; + + results.add({ + 'gasMix': GasMix(o2: o2, he: he), + 'startPressure': _parsePsiPressure(tank['StartPressurePSI']), + 'endPressure': _parsePsiPressure(tank['EndPressurePSI']), + 'name': transmitter['Name'] as String? ?? '', + }); + } + + return results; + } + + /// Deduplicates dive sites by name across a list of raw dives. + /// + /// Each unique site name produces one site entity map with `name`, + /// `uddfId`, and optional location fields. + static List> mapSites(List dives) { + final siteMap = >{}; + + for (final dive in dives) { + final name = dive.site; + if (name == null) continue; + + siteMap.putIfAbsent(name, () { + final site = {'name': name, 'uddfId': name}; + + if (dive.location != null) { + site['notes'] = dive.location; + } + + final coords = _parseGnssLocation(dive.gnssEntryLocation); + if (coords != null) { + site['latitude'] = coords.$1; + site['longitude'] = coords.$2; + } + + return site; + }); + } + + return siteMap.values.toList(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + static bool _isImperial(ShearwaterRawDive rawDive) { + final unitSystem = rawDive.footerJson?['UnitSystem']; + return unitSystem == 1; + } + + static DateTime? _parseDateTime(String? dateStr) { + if (dateStr == null) return null; + return DateTime.tryParse(dateStr); + } + + static int? _parseInt(String? value) { + if (value == null) return null; + return int.tryParse(value); + } + + static double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + static double? _convertTemperature(String? value, bool isImperial) { + if (value == null) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + return isImperial + ? ShearwaterValueMapper.fahrenheitToCelsius(numValue) + : numValue; + } + + static double? _convertWeight(String? value, bool isImperial) { + if (value == null) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + return isImperial ? ShearwaterValueMapper.lbsToKg(numValue) : numValue; + } + + /// Parses a PSI string value and converts to bar. + /// + /// Returns null for empty or non-numeric values. + static int? _parsePsiPressure(dynamic value) { + if (value == null) return null; + final str = value.toString(); + if (str.isEmpty) return null; + final psi = double.tryParse(str); + if (psi == null) return null; + return ShearwaterValueMapper.psiToBar(psi).round(); + } + + static DiveMode _mapDiveMode(String? apparatus) { + if (apparatus == null) return DiveMode.oc; + final lower = apparatus.toLowerCase(); + if (lower.contains('closed circuit') || lower == 'ccr') return DiveMode.ccr; + if (lower.contains('semi-closed') || lower == 'scr') return DiveMode.scr; + return DiveMode.oc; + } + + static String _buildNotes(ShearwaterRawDive rawDive) { + final userNotes = rawDive.notes; + final extraNotes = ShearwaterValueMapper.buildExtraNotes( + weather: rawDive.weather, + conditions: rawDive.conditions, + dress: rawDive.dress, + thermalComfort: rawDive.thermalComfort, + workload: rawDive.workload, + problems: rawDive.problems, + malfunctions: rawDive.malfunctions, + symptoms: rawDive.symptoms, + gasNotes: rawDive.gasNotes, + gearNotes: rawDive.gearNotes, + issueNotes: rawDive.issueNotes, + ); + + if (userNotes != null && extraNotes != null) { + return '$userNotes\n\n$extraNotes'; + } + return userNotes ?? extraNotes ?? ''; + } + + /// Extracts surface pressure in bar from the first tank's + /// SurfacePressureMBar field. + static double? _extractSurfacePressure(ShearwaterRawDive rawDive) { + final tankData = + rawDive.tankProfileData?['TankData'] as List? ?? []; + if (tankData.isEmpty) return null; + + final firstTank = tankData[0] as Map; + final mbar = _toDouble(firstTank['SurfacePressureMBar']); + if (mbar == null) return null; + return ShearwaterValueMapper.mbarToBar(mbar); + } + + /// Parses a GNSS location string "lat,lon" into a coordinate pair. + static (double, double)? _parseGnssLocation(String? gnss) { + if (gnss == null || gnss.isEmpty) return null; + final parts = gnss.split(','); + if (parts.length != 2) return null; + final lat = double.tryParse(parts[0].trim()); + final lon = double.tryParse(parts[1].trim()); + if (lat == null || lon == null) return null; + return (lat, lon); + } + + static Future _parseWithFfi({ + required String vendor, + required String product, + required Uint8List data, + }) async { + // Look up the libdivecomputer model number. For now, pass 0 as a + // placeholder -- the native side resolves by vendor + product name. + final api = pigeon.DiveComputerHostApi(); + return api.parseRawDiveData(vendor, product, 0, data); + } + + /// Merges parsed profile and deco data into the base metadata map. + static Map _mergeWithParsedDive( + Map baseMap, + pigeon.ParsedDive parsed, + ) { + final merged = Map.from(baseMap); + + // Override depth/duration with parsed values (more accurate) + merged['maxDepth'] = parsed.maxDepthMeters; + merged['avgDepth'] = parsed.avgDepthMeters; + merged['runtime'] = Duration(seconds: parsed.durationSeconds); + + // Add deco model info + if (parsed.decoAlgorithm != null) { + merged['decoAlgorithm'] = parsed.decoAlgorithm; + } + if (parsed.gfLow != null) { + merged['gradientFactorLow'] = parsed.gfLow; + } + if (parsed.gfHigh != null) { + merged['gradientFactorHigh'] = parsed.gfHigh; + } + + // Build profile samples + merged['profile'] = parsed.samples.map((s) { + final sampleMap = { + 'timestamp': s.timeSeconds, + 'depth': s.depthMeters, + }; + if (s.temperatureCelsius != null) { + sampleMap['temperature'] = s.temperatureCelsius; + } + if (s.pressureBar != null) { + sampleMap['pressure'] = s.pressureBar; + } + return sampleMap; + }).toList(); + + // Extract water temperature from profile samples if not already set + if (merged['waterTemp'] == null) { + final temps = parsed.samples + .map((s) => s.temperatureCelsius) + .whereType() + .toList(); + if (temps.isNotEmpty) { + merged['waterTemp'] = temps.reduce((a, b) => a < b ? a : b); + } + } + + return merged; + } +} diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt index e6dc7d81c..e5bbb52f0 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt @@ -495,6 +495,7 @@ interface DiveComputerHostApi { fun cancelDownload() fun submitPinCode(pinCode: String) fun getLibdivecomputerVersion(): String + fun parseRawDiveData(vendor: String, product: String, model: Long, data: ByteArray, callback: (Result) -> Unit) companion object { /** The codec used by DiveComputerHostApi. */ @@ -627,6 +628,29 @@ interface DiveComputerHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val vendorArg = args[0] as String + val productArg = args[1] as String + val modelArg = args[2] as Long + val dataArg = args[3] as ByteArray + api.parseRawDiveData(vendorArg, productArg, modelArg, dataArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt index 274921a8a..f9d2df309 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt @@ -395,6 +395,21 @@ class DiveComputerHostApiImpl( ) } + // MARK: - Parse Raw Dive Data + + override fun parseRawDiveData( + vendor: String, + product: String, + model: Long, + data: ByteArray, + callback: (Result) -> Unit + ) { + callback(Result.failure( + FlutterError("UNSUPPORTED", + "Raw dive parsing not yet implemented on Android", + null))) + } + // MARK: - Version override fun getLibdivecomputerVersion(): String { diff --git a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift index bdc6574d5..7569064a3 100644 --- a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift +++ b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift @@ -568,6 +568,57 @@ class DiveComputerHostApiImpl: DiveComputerHostApi { } } + // MARK: - Parse Raw Dive Data + + func parseRawDiveData( + vendor: String, + product: String, + model: Int64, + data: FlutterStandardTypedData, + completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { [self] in + var dive = libdc_parsed_dive_t() + var errorBuf = [CChar](repeating: 0, count: 256) + + let result = data.data.withUnsafeBytes { rawPtr -> Int32 in + guard let baseAddress = rawPtr.baseAddress else { return -1 } + return libdc_parse_raw_dive( + vendor, + product, + UInt32(model), + baseAddress.assumingMemoryBound(to: UInt8.self), + UInt32(data.data.count), + &dive, + &errorBuf, + errorBuf.count + ) + } + + if result != 0 { + let errorMsg = String(cString: errorBuf) + free(dive.samples) + free(dive.events) + DispatchQueue.main.async { + completion(.failure(PigeonError( + code: "PARSE_ERROR", + message: "Failed to parse raw dive data: \(errorMsg)", + details: nil + ))) + } + return + } + + let parsedDive = self.convertParsedDive(dive) + free(dive.samples) + free(dive.events) + + DispatchQueue.main.async { + completion(.success(parsedDive)) + } + } + } + // MARK: - Version func getLibdivecomputerVersion() throws -> String { diff --git a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift index b90484971..c850e78d7 100644 --- a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift +++ b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift @@ -562,6 +562,7 @@ protocol DiveComputerHostApi { func cancelDownload() throws func submitPinCode(pinCode: String) throws func getLibdivecomputerVersion() throws -> String + func parseRawDiveData(vendor: String, product: String, model: Int64, data: FlutterStandardTypedData, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -674,6 +675,26 @@ class DiveComputerHostApiSetup { } else { getLibdivecomputerVersionChannel.setMessageHandler(nil) } + let parseRawDiveDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + parseRawDiveDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let vendorArg = args[0] as! String + let productArg = args[1] as! String + let modelArg = args[2] as! Int64 + let dataArg = args[3] as! FlutterStandardTypedData + api.parseRawDiveData(vendor: vendorArg, product: productArg, model: modelArg, data: dataArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + parseRawDiveDataChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart b/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart index 39ca81de9..89c4130fd 100644 --- a/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart +++ b/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart @@ -722,6 +722,41 @@ class DiveComputerHostApi { return (pigeonVar_replyList[0] as String?)!; } } + + Future parseRawDiveData( + String vendor, + String product, + int model, + Uint8List data, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([vendor, product, model, data]) + as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as ParsedDive?)!; + } + } } abstract class DiveComputerFlutterApi { diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc index 3b50ac536..f045ed647 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc @@ -1801,6 +1801,45 @@ LibdivecomputerPluginDiveComputerHostApiGetLibdivecomputerVersionResponse* libdi return self; } +G_DECLARE_FINAL_TYPE(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response, LIBDIVECOMPUTER_PLUGIN, DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE, GObject) + +struct _LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse { + GObject parent_instance; + + FlValue* value; +}; + +G_DEFINE_TYPE(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response, G_TYPE_OBJECT) + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_dispose(GObject* object) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(object); + g_clear_pointer(&self->value, fl_value_unref); + G_OBJECT_CLASS(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_parent_class)->dispose(object); +} + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_init(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self) { +} + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_class_init(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponseClass* klass) { + G_OBJECT_CLASS(klass)->dispose = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_dispose; +} + +static LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new(LibdivecomputerPluginParsedDive* return_value) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(g_object_new(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_custom_object(136, G_OBJECT(return_value))); + return self; +} + +static LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new_error(const gchar* code, const gchar* message, FlValue* details) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(g_object_new(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_string(code)); + fl_value_append_take(self->value, fl_value_new_string(message != nullptr ? message : "")); + fl_value_append_take(self->value, details != nullptr ? fl_value_ref(details) : fl_value_new_null()); + return self; +} + struct _LibdivecomputerPluginDiveComputerHostApi { GObject parent_instance; @@ -1952,6 +1991,26 @@ static void libdivecomputer_plugin_dive_computer_host_api_get_libdivecomputer_ve } } +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_cb(FlBasicMessageChannel* channel, FlValue* message_, FlBasicMessageChannelResponseHandle* response_handle, gpointer user_data) { + LibdivecomputerPluginDiveComputerHostApi* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API(user_data); + + if (self->vtable == nullptr || self->vtable->parse_raw_dive_data == nullptr) { + return; + } + + FlValue* value0 = fl_value_get_list_value(message_, 0); + const gchar* vendor = fl_value_get_string(value0); + FlValue* value1 = fl_value_get_list_value(message_, 1); + const gchar* product = fl_value_get_string(value1); + FlValue* value2 = fl_value_get_list_value(message_, 2); + int64_t model = fl_value_get_int(value2); + FlValue* value3 = fl_value_get_list_value(message_, 3); + const uint8_t* data = fl_value_get_uint8_list(value3); + size_t data_length = fl_value_get_length(value3); + g_autoptr(LibdivecomputerPluginDiveComputerHostApiResponseHandle) handle = libdivecomputer_plugin_dive_computer_host_api_response_handle_new(channel, response_handle); + self->vtable->parse_raw_dive_data(vendor, product, model, data, data_length, handle, self->user_data); +} + void libdivecomputer_plugin_dive_computer_host_api_set_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix, const LibdivecomputerPluginDiveComputerHostApiVTable* vtable, gpointer user_data, GDestroyNotify user_data_free_func) { g_autofree gchar* dot_suffix = suffix != nullptr ? g_strdup_printf(".%s", suffix) : g_strdup(""); g_autoptr(LibdivecomputerPluginDiveComputerHostApi) api_data = libdivecomputer_plugin_dive_computer_host_api_new(vtable, user_data, user_data_free_func); @@ -1978,6 +2037,9 @@ void libdivecomputer_plugin_dive_computer_host_api_set_method_handlers(FlBinaryM g_autofree gchar* get_libdivecomputer_version_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.getLibdivecomputerVersion%s", dot_suffix); g_autoptr(FlBasicMessageChannel) get_libdivecomputer_version_channel = fl_basic_message_channel_new(messenger, get_libdivecomputer_version_channel_name, FL_MESSAGE_CODEC(codec)); fl_basic_message_channel_set_message_handler(get_libdivecomputer_version_channel, libdivecomputer_plugin_dive_computer_host_api_get_libdivecomputer_version_cb, g_object_ref(api_data), g_object_unref); + g_autofree gchar* parse_raw_dive_data_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) parse_raw_dive_data_channel = fl_basic_message_channel_new(messenger, parse_raw_dive_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(parse_raw_dive_data_channel, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_cb, g_object_ref(api_data), g_object_unref); } void libdivecomputer_plugin_dive_computer_host_api_clear_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix) { @@ -2005,6 +2067,9 @@ void libdivecomputer_plugin_dive_computer_host_api_clear_method_handlers(FlBinar g_autofree gchar* get_libdivecomputer_version_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.getLibdivecomputerVersion%s", dot_suffix); g_autoptr(FlBasicMessageChannel) get_libdivecomputer_version_channel = fl_basic_message_channel_new(messenger, get_libdivecomputer_version_channel_name, FL_MESSAGE_CODEC(codec)); fl_basic_message_channel_set_message_handler(get_libdivecomputer_version_channel, nullptr, nullptr, nullptr); + g_autofree gchar* parse_raw_dive_data_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) parse_raw_dive_data_channel = fl_basic_message_channel_new(messenger, parse_raw_dive_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(parse_raw_dive_data_channel, nullptr, nullptr, nullptr); } void libdivecomputer_plugin_dive_computer_host_api_respond_get_device_descriptors(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, FlValue* return_value) { @@ -2055,6 +2120,22 @@ void libdivecomputer_plugin_dive_computer_host_api_respond_error_start_download( } } +void libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, LibdivecomputerPluginParsedDive* return_value) { + g_autoptr(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse) response = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new(return_value); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "DiveComputerHostApi", "parseRawDiveData", error->message); + } +} + +void libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details) { + g_autoptr(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse) response = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new_error(code, message, details); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "DiveComputerHostApi", "parseRawDiveData", error->message); + } +} + struct _LibdivecomputerPluginDiveComputerFlutterApi { GObject parent_instance; diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h index d0917c3ad..e67469745 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h @@ -960,6 +960,7 @@ typedef struct { LibdivecomputerPluginDiveComputerHostApiCancelDownloadResponse* (*cancel_download)(gpointer user_data); LibdivecomputerPluginDiveComputerHostApiSubmitPinCodeResponse* (*submit_pin_code)(const gchar* pin_code, gpointer user_data); LibdivecomputerPluginDiveComputerHostApiGetLibdivecomputerVersionResponse* (*get_libdivecomputer_version)(gpointer user_data); + void (*parse_raw_dive_data)(const gchar* vendor, const gchar* product, int64_t model, const uint8_t* data, size_t data_length, LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, gpointer user_data); } LibdivecomputerPluginDiveComputerHostApiVTable; /** @@ -1043,6 +1044,26 @@ void libdivecomputer_plugin_dive_computer_host_api_respond_start_download(Libdiv */ void libdivecomputer_plugin_dive_computer_host_api_respond_error_start_download(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); +/** + * libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data: + * @response_handle: a #LibdivecomputerPluginDiveComputerHostApiResponseHandle. + * @return_value: location to write the value returned by this method. + * + * Responds to DiveComputerHostApi.parseRawDiveData. + */ +void libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, LibdivecomputerPluginParsedDive* return_value); + +/** + * libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data: + * @response_handle: a #LibdivecomputerPluginDiveComputerHostApiResponseHandle. + * @code: error code. + * @message: error message. + * @details: (allow-none): error details or %NULL. + * + * Responds with an error to DiveComputerHostApi.parseRawDiveData. + */ +void libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); + G_DECLARE_FINAL_TYPE(LibdivecomputerPluginDiveComputerFlutterApiOnDeviceDiscoveredResponse, libdivecomputer_plugin_dive_computer_flutter_api_on_device_discovered_response, LIBDIVECOMPUTER_PLUGIN, DIVE_COMPUTER_FLUTTER_API_ON_DEVICE_DISCOVERED_RESPONSE, GObject) /** diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc b/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc index 0e227d506..8d8fa8c97 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc @@ -690,6 +690,19 @@ handle_get_libdivecomputer_version(gpointer user_data) { version); } +static void handle_parse_raw_dive_data( + const gchar* vendor, + const gchar* product, + int64_t model, + const uint8_t* data, + size_t data_length, + LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, + gpointer user_data) { + libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data( + response_handle, "UNSUPPORTED", + "Raw dive parsing not yet implemented on Linux", nullptr); +} + // --- Public registration --- void dive_computer_host_api_impl_register(FlBinaryMessenger* messenger) { @@ -712,6 +725,7 @@ void dive_computer_host_api_impl_register(FlBinaryMessenger* messenger) { .cancel_download = handle_cancel_download, .submit_pin_code = handle_submit_pin_code, .get_libdivecomputer_version = handle_get_libdivecomputer_version, + .parse_raw_dive_data = handle_parse_raw_dive_data, }; libdivecomputer_plugin_dive_computer_host_api_set_method_handlers( diff --git a/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift b/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift index b90484971..c850e78d7 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift +++ b/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift @@ -562,6 +562,7 @@ protocol DiveComputerHostApi { func cancelDownload() throws func submitPinCode(pinCode: String) throws func getLibdivecomputerVersion() throws -> String + func parseRawDiveData(vendor: String, product: String, model: Int64, data: FlutterStandardTypedData, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -674,6 +675,26 @@ class DiveComputerHostApiSetup { } else { getLibdivecomputerVersionChannel.setMessageHandler(nil) } + let parseRawDiveDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + parseRawDiveDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let vendorArg = args[0] as! String + let productArg = args[1] as! String + let modelArg = args[2] as! Int64 + let dataArg = args[3] as! FlutterStandardTypedData + api.parseRawDiveData(vendor: vendorArg, product: productArg, model: modelArg, data: dataArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + parseRawDiveDataChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart index 1cf30d40e..b4b1f4f73 100644 --- a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart +++ b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart @@ -202,6 +202,14 @@ abstract class DiveComputerHostApi { void submitPinCode(String pinCode); String getLibdivecomputerVersion(); + + @async + ParsedDive parseRawDiveData( + String vendor, + String product, + int model, + Uint8List data, + ); } // === Flutter API (Native -> Dart) === diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc index d879a6ff3..cdf1dbaeb 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc @@ -1496,6 +1496,53 @@ void DiveComputerHostApi::SetUp( channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_vendor_arg = args.at(0); + if (encodable_vendor_arg.IsNull()) { + reply(WrapError("vendor_arg unexpectedly null.")); + return; + } + const auto& vendor_arg = std::get(encodable_vendor_arg); + const auto& encodable_product_arg = args.at(1); + if (encodable_product_arg.IsNull()) { + reply(WrapError("product_arg unexpectedly null.")); + return; + } + const auto& product_arg = std::get(encodable_product_arg); + const auto& encodable_model_arg = args.at(2); + if (encodable_model_arg.IsNull()) { + reply(WrapError("model_arg unexpectedly null.")); + return; + } + const int64_t model_arg = encodable_model_arg.LongValue(); + const auto& encodable_data_arg = args.at(3); + if (encodable_data_arg.IsNull()) { + reply(WrapError("data_arg unexpectedly null.")); + return; + } + const auto& data_arg = std::get>(encodable_data_arg); + api->ParseRawDiveData(vendor_arg, product_arg, model_arg, data_arg, [reply](ErrorOr&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } } EncodableValue DiveComputerHostApi::WrapError(std::string_view error_message) { diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h index 8d6aadd53..f565bd3d3 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h @@ -630,6 +630,12 @@ class DiveComputerHostApi { virtual std::optional CancelDownload() = 0; virtual std::optional SubmitPinCode(const std::string& pin_code) = 0; virtual ErrorOr GetLibdivecomputerVersion() = 0; + virtual void ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) = 0; // The codec used by DiveComputerHostApi. static const flutter::StandardMessageCodec& GetCodec(); diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc index 7ccc1613e..d1393c510 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc @@ -154,6 +154,16 @@ std::optional DiveComputerHostApiImpl::SubmitPinCode( return std::nullopt; } +void DiveComputerHostApiImpl::ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) { + result(FlutterError("UNSUPPORTED", + "Raw dive parsing not yet implemented on Windows")); +} + ErrorOr DiveComputerHostApiImpl::GetLibdivecomputerVersion() { const char* version = libdc_get_version(); return std::string(version); diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h index 610915aa9..ffed194bc 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h @@ -46,6 +46,13 @@ class DiveComputerHostApiImpl : public DiveComputerHostApi, std::optional SubmitPinCode(const std::string& pin_code) override; + void ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) override; + ErrorOr GetLibdivecomputerVersion() override; private: diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart new file mode 100644 index 000000000..ec2e7b011 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart @@ -0,0 +1,513 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +void main() { + group('ShearwaterDiveMapper', () { + group('mapDiveMetadata', () { + test('maps core metadata fields', () { + final rawDive = ShearwaterRawDive( + diveId: 'test123', + diveDate: '2025-12-27 14:01:08', + depth: 26.80, + averageDepth: 19.45, + diveLengthTime: 1764, + diveNumber: '23', + serialNumber: '69FE56D7', + location: 'Shark River, NJ, USA', + site: 'Maclearie Park', + buddy: 'John Doe', + notes: 'Great dive', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + airTemperature: '72', + weight: '14', + dress: 'Wet Suit', + fileName: 'Teric[69FE56D7]#23 2025-12-27 14-01-08.swlogzp', + footerJson: {'UnitSystem': 1, 'DiveTimeInSeconds': 1764}, + tankProfileData: { + 'GasProfiles': [ + {'O2Percent': 32, 'HePercent': 0, 'CircuitMode': 1}, + ], + 'TankData': [ + { + 'StartPressurePSI': '2960', + 'EndPressurePSI': '1088', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1015.0, + }, + ], + }, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'test123'); + expect(result['dateTime'], isA()); + expect((result['dateTime'] as DateTime).year, 2025); + expect((result['dateTime'] as DateTime).month, 12); + expect((result['dateTime'] as DateTime).day, 27); + expect(result['maxDepth'], 26.80); + expect(result['avgDepth'], 19.45); + expect(result['runtime'], const Duration(seconds: 1764)); + expect(result['diveNumber'], 23); + expect(result['buddyRefs'], ['John Doe']); + expect(result['siteName'], 'Maclearie Park'); + expect(result['diveComputerModel'], 'Teric'); + expect(result['diveComputerSerial'], '69FE56D7'); + expect(result['notes'], contains('Great dive')); + }); + + test('maps conditions to structured enums', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + footerJson: {'UnitSystem': 1}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['waterType'], WaterType.salt); + expect(result['visibility'], Visibility.moderate); + expect(result['cloudCover'], CloudCover.clear); + expect(result['currentStrength'], CurrentStrength.moderate); + }); + + test('converts imperial units', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + airTemperature: '72', + weight: '14', + footerJson: {'UnitSystem': 1}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + // 72F = 22.2C + expect(result['airTemp'], closeTo(22.2, 0.1)); + // 14lbs = 6.35kg + expect(result['weightAmount'], closeTo(6.35, 0.01)); + }); + + test('does not convert metric units', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + airTemperature: '22', + weight: '6', + footerJson: {'UnitSystem': 0}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['airTemp'], closeTo(22.0, 0.1)); + expect(result['weightAmount'], closeTo(6.0, 0.01)); + }); + + test('includes notes with extra notes appended', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + notes: 'Great dive', + dress: 'Wet Suit', + footerJson: {'UnitSystem': 0}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final notes = result['notes'] as String; + + expect(notes, contains('Great dive')); + expect(notes, contains('Dress: Wet Suit')); + }); + + test('handles notes when only user notes present', () { + final rawDive = ShearwaterRawDive(diveId: 'test', notes: 'Great dive'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['notes'], 'Great dive'); + }); + + test('handles notes when only extra notes present', () { + final rawDive = ShearwaterRawDive(diveId: 'test', dress: 'Dry Suit'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final notes = result['notes'] as String; + + expect(notes, contains('Dress: Dry Suit')); + }); + + test('maps site reference for matching', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'Maclearie Park', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['siteName'], 'Maclearie Park'); + expect(result['site'], isA>()); + expect((result['site'] as Map)['name'], 'Maclearie Park'); + expect((result['site'] as Map)['uddfId'], 'Maclearie Park'); + }); + + test('extracts surface pressure from tank data', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': 21, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + // 1013 mbar = 1.013 bar + expect(result['surfacePressure'], closeTo(1.013, 0.001)); + }); + + test('handles dive with no metadata gracefully', () { + final rawDive = ShearwaterRawDive(diveId: 'empty'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'empty'); + expect(result['dateTime'], isNull); + expect(result['maxDepth'], isNull); + expect(result['tanks'], isEmpty); + }); + + test('maps dive computer info from filename', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + fileName: 'Perdix 2[AABB1234]#5 2025-01-15 09-30-00.swlogzp', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveComputerModel'], 'Perdix 2'); + expect(result['diveComputerSerial'], 'AABB1234'); + }); + + test('defaults to oc diveMode when no apparatus set', () { + final rawDive = ShearwaterRawDive(diveId: 'test'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.oc); + }); + + test('maps CCR apparatus to ccr diveMode', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + apparatus: 'Closed Circuit', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.ccr); + }); + + test('maps SCR apparatus to scr diveMode', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + apparatus: 'Semi-Closed', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.scr); + }); + }); + + group('mapTanks', () { + test('maps active tanks with gas mix and pressures', () { + final rawDive = ShearwaterRawDive( + diveId: 'test123', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '2960', + 'EndPressurePSI': '1088', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1015.0, + }, + { + 'StartPressurePSI': '', + 'EndPressurePSI': '', + 'GasProfile': {'O2Percent': 21, 'HePercent': 0}, + 'DiveTransmitter': { + 'TankIndex': 1, + 'IsOn': false, + 'Name': 'T2', + }, + 'SurfacePressureMBar': 1015.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32); + expect((tanks[0]['gasMix'] as GasMix).he, 0); + expect(tanks[0]['startPressure'], closeTo(204.1, 0.5)); + expect(tanks[0]['endPressure'], closeTo(75.0, 0.5)); + expect(tanks[0]['name'], 'T1'); + }); + + test('maps multiple active tanks', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '2800', + 'GasProfile': {'O2Percent': 100, 'HePercent': 0}, + 'DiveTransmitter': { + 'TankIndex': 1, + 'IsOn': true, + 'Name': 'Deco', + }, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(2)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32); + expect(tanks[0]['name'], 'T1'); + expect((tanks[1]['gasMix'] as GasMix).o2, 100); + expect(tanks[1]['name'], 'Deco'); + }); + + test('maps trimix tank', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': 21, 'HePercent': 35}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 21); + expect((tanks[0]['gasMix'] as GasMix).he, 35); + }); + + test('returns empty list when no tank data', () { + final rawDive = ShearwaterRawDive(diveId: 'test'); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, isEmpty); + }); + + test('returns empty list when TankData is missing', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: {'GasProfiles': []}, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, isEmpty); + }); + + test('handles tanks with missing pressure values', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '', + 'EndPressurePSI': '', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect(tanks[0]['startPressure'], isNull); + expect(tanks[0]['endPressure'], isNull); + }); + }); + + group('mapSites', () { + test('maps site from location and site fields', () { + final rawDive = ShearwaterRawDive( + diveId: 'test123', + location: 'Shark River, NJ, USA', + site: 'Maclearie Park', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites, hasLength(1)); + expect(sites[0]['name'], 'Maclearie Park'); + expect(sites[0]['uddfId'], 'Maclearie Park'); + }); + + test('deduplicates sites by name', () { + final dives = [ + ShearwaterRawDive(diveId: '1', site: 'Same Site', location: 'NJ'), + ShearwaterRawDive(diveId: '2', site: 'Same Site', location: 'NJ'), + ShearwaterRawDive(diveId: '3', site: 'Other Site', location: 'FL'), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, hasLength(2)); + final names = sites.map((s) => s['name']).toSet(); + expect(names, containsAll(['Same Site', 'Other Site'])); + }); + + test('skips dives with no site name', () { + final dives = [ + ShearwaterRawDive(diveId: '1', site: 'Named Site'), + ShearwaterRawDive(diveId: '2'), + ShearwaterRawDive(diveId: '3', site: null), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, hasLength(1)); + expect(sites[0]['name'], 'Named Site'); + }); + + test('returns empty list when no dives have sites', () { + final dives = [ + ShearwaterRawDive(diveId: '1'), + ShearwaterRawDive(diveId: '2'), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, isEmpty); + }); + + test('includes location as notes', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'My Site', + location: 'Some Location', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites[0]['notes'], 'Some Location'); + }); + + test('parses GNSS entry location to lat/lon', () { + final rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'GPS Site', + gnssEntryLocation: '40.1234,-74.5678', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites[0]['latitude'], closeTo(40.1234, 0.0001)); + expect(sites[0]['longitude'], closeTo(-74.5678, 0.0001)); + }); + }); + + group('mapDive', () { + test('falls back to metadata when no decompressed data', () async { + final rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + ); + + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'ffi-test'); + expect(result['maxDepth'], 20.0); + // No warning expected because there was no decompressed data to parse + expect(warnings, isEmpty); + }); + + test('adds warning when FFI throws', () async { + // Provide decompressed data so it attempts FFI parsing, + // which will throw MissingPluginException in test environment + final rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + fileName: 'Teric[AABB1234]#10 2025-06-15 10-30-00.swlogzp', + decompressedLogData: Uint8List.fromList(List.filled(100, 0)), + ); + + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + + // Should still produce a valid map from metadata fallback + expect(result['importSource'], 'shearwater_cloud'); + expect(result['maxDepth'], 20.0); + // Should have a warning about FFI failure + expect(warnings, hasLength(1)); + expect(warnings[0].severity, ImportWarningSeverity.warning); + }); + }); + }); +} From 00a8cdc6a1e963597e037505c51ccf76c30456bd Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 05:10:46 -0400 Subject: [PATCH 03/12] Task 8: wire ShearwaterCloudParser into import system - Mark ImportFormat.shearwaterDb as supported in isSupported getter - Set SourceApp.shearwater exportInstructions to null (native .db import supported) - Remove shearwaterDb from PlaceholderParser supportedFormats - Add shearwaterDb case in _parserFor() routing to ShearwaterCloudParser - Add SQLite pre-validation in pickFile() to detect Shearwater Cloud databases - Update tests to reflect new shearwaterDb supported status --- .../data/models/import_enums.dart | 6 ++---- .../data/parsers/placeholder_parser.dart | 1 - .../providers/universal_import_providers.dart | 18 ++++++++++++++++- .../data/models/detection_result_test.dart | 2 +- .../data/models/import_enums_test.dart | 20 +++++++++++-------- .../data/parsers/placeholder_parser_test.dart | 5 ++++- 6 files changed, 36 insertions(+), 16 deletions(-) diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index bb4bf35d9..9b2e8fdf0 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -30,7 +30,7 @@ enum ImportFormat { /// Whether this format has a parser implemented in v1.5. bool get isSupported => switch (this) { - csv || uddf || subsurfaceXml || fit => true, + csv || uddf || subsurfaceXml || fit || shearwaterDb => true, _ => false, }; } @@ -67,9 +67,7 @@ enum SourceApp { /// Instructions for exporting from this app in a supported format. String? get exportInstructions => switch (this) { - shearwater => - 'In Shearwater Cloud Desktop, go to File > Export > UDDF to create a ' - 'file that Submersion can import.', + shearwater => null, // Native .db import supported suunto => 'In Suunto DM5, select your dives and go to File > Export > UDDF.', scubapro => diff --git a/lib/features/universal_import/data/parsers/placeholder_parser.dart b/lib/features/universal_import/data/parsers/placeholder_parser.dart index d2a87acf5..4116dc1bd 100644 --- a/lib/features/universal_import/data/parsers/placeholder_parser.dart +++ b/lib/features/universal_import/data/parsers/placeholder_parser.dart @@ -19,7 +19,6 @@ class PlaceholderParser implements ImportParser { ImportFormat.divingLogXml, ImportFormat.suuntoSml, ImportFormat.suuntoDm5, - ImportFormat.shearwaterDb, ImportFormat.scubapro, ImportFormat.danDl7, ImportFormat.sqlite, diff --git a/lib/features/universal_import/presentation/providers/universal_import_providers.dart b/lib/features/universal_import/presentation/providers/universal_import_providers.dart index 6079f1f16..349a86cb7 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -35,8 +35,10 @@ import 'package:submersion/features/universal_import/data/parsers/fit_import_par import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/placeholder_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/subsurface_xml_parser.dart'; +import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/uddf_import_parser.dart'; import 'package:submersion/features/universal_import/data/services/format_detector.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; // ============================================================================ @@ -257,7 +259,20 @@ class UniversalImportNotifier extends StateNotifier { final fileName = pickedFile.name; const detector = FormatDetector(); - final detection = detector.detect(bytes); + var detection = detector.detect(bytes); + + if (detection.format == ImportFormat.sqlite) { + final isShearwater = await ShearwaterDbReader.isShearwaterCloudDb( + bytes, + ); + if (isShearwater) { + detection = const DetectionResult( + format: ImportFormat.shearwaterDb, + sourceApp: SourceApp.shearwater, + confidence: 0.95, + ); + } + } state = state.copyWith( isLoading: false, @@ -400,6 +415,7 @@ class UniversalImportNotifier extends StateNotifier { ImportFormat.uddf => UddfImportParser(), ImportFormat.subsurfaceXml => SubsurfaceXmlParser(), ImportFormat.fit => const FitImportParser(), + ImportFormat.shearwaterDb => ShearwaterCloudParser(), _ => const PlaceholderParser(), }; } diff --git a/test/features/universal_import/data/models/detection_result_test.dart b/test/features/universal_import/data/models/detection_result_test.dart index 803a437c2..47796fc7b 100644 --- a/test/features/universal_import/data/models/detection_result_test.dart +++ b/test/features/universal_import/data/models/detection_result_test.dart @@ -77,7 +77,7 @@ void main() { test('returns false for unsupported formats', () { const result = DetectionResult( - format: ImportFormat.shearwaterDb, + format: ImportFormat.divingLogXml, confidence: 0.9, ); diff --git a/test/features/universal_import/data/models/import_enums_test.dart b/test/features/universal_import/data/models/import_enums_test.dart index e3135a0f7..a679a07a7 100644 --- a/test/features/universal_import/data/models/import_enums_test.dart +++ b/test/features/universal_import/data/models/import_enums_test.dart @@ -43,18 +43,21 @@ void main() { expect(ImportFormat.unknown.displayName, 'Unknown'); }); - test('isSupported returns true for csv, uddf, subsurfaceXml, fit', () { - expect(ImportFormat.csv.isSupported, isTrue); - expect(ImportFormat.uddf.isSupported, isTrue); - expect(ImportFormat.subsurfaceXml.isSupported, isTrue); - expect(ImportFormat.fit.isSupported, isTrue); - }); + test( + 'isSupported returns true for csv, uddf, subsurfaceXml, fit, shearwaterDb', + () { + expect(ImportFormat.csv.isSupported, isTrue); + expect(ImportFormat.uddf.isSupported, isTrue); + expect(ImportFormat.subsurfaceXml.isSupported, isTrue); + expect(ImportFormat.fit.isSupported, isTrue); + expect(ImportFormat.shearwaterDb.isSupported, isTrue); + }, + ); test('isSupported returns false for unsupported formats', () { expect(ImportFormat.divingLogXml.isSupported, isFalse); expect(ImportFormat.suuntoSml.isSupported, isFalse); expect(ImportFormat.suuntoDm5.isSupported, isFalse); - expect(ImportFormat.shearwaterDb.isSupported, isFalse); expect(ImportFormat.scubapro.isSupported, isFalse); expect(ImportFormat.danDl7.isSupported, isFalse); expect(ImportFormat.sqlite.isSupported, isFalse); @@ -83,7 +86,6 @@ void main() { }); test('exportInstructions is non-null for known apps', () { - expect(SourceApp.shearwater.exportInstructions, isNotNull); expect(SourceApp.suunto.exportInstructions, isNotNull); expect(SourceApp.scubapro.exportInstructions, isNotNull); expect(SourceApp.ssiMyDiveGuide.exportInstructions, isNotNull); @@ -91,6 +93,8 @@ void main() { }); test('exportInstructions is null for apps without instructions', () { + // shearwater now uses native .db import, so no export instructions needed + expect(SourceApp.shearwater.exportInstructions, isNull); expect(SourceApp.submersion.exportInstructions, isNull); expect(SourceApp.subsurface.exportInstructions, isNull); expect(SourceApp.macdive.exportInstructions, isNull); diff --git a/test/features/universal_import/data/parsers/placeholder_parser_test.dart b/test/features/universal_import/data/parsers/placeholder_parser_test.dart index 668989676..2741f097d 100644 --- a/test/features/universal_import/data/parsers/placeholder_parser_test.dart +++ b/test/features/universal_import/data/parsers/placeholder_parser_test.dart @@ -14,11 +14,14 @@ void main() { expect(parser.supportedFormats, contains(ImportFormat.divingLogXml)); expect(parser.supportedFormats, contains(ImportFormat.suuntoSml)); expect(parser.supportedFormats, contains(ImportFormat.suuntoDm5)); - expect(parser.supportedFormats, contains(ImportFormat.shearwaterDb)); expect(parser.supportedFormats, contains(ImportFormat.scubapro)); expect(parser.supportedFormats, contains(ImportFormat.danDl7)); expect(parser.supportedFormats, contains(ImportFormat.sqlite)); expect(parser.supportedFormats, contains(ImportFormat.unknown)); + expect( + parser.supportedFormats, + isNot(contains(ImportFormat.shearwaterDb)), + ); }); }); From 69a9c70f785d1c30dd14e31f83daa9d2f291419b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 05:15:11 -0400 Subject: [PATCH 04/12] feat: add Shearwater Cloud parser, DB reader, filename parser, and value mapper - ShearwaterCloudParser: orchestrates DB reading, FFI profile parsing, and metadata mapping into standard ImportPayload - ShearwaterDbReader: opens SQLite, queries dive_details + log_data, decompresses binary BLOBs, parses JSON fields - ShearwaterFilenameParser: extracts model/serial from log_data filenames - ShearwaterValueMapper: unit conversions (PSI->bar, F->C, etc.) and condition enum mapping - Full TDD test coverage (54 tests) --- .../data/parsers/shearwater_cloud_parser.dart | 88 +++++ .../data/services/shearwater_db_reader.dart | 302 ++++++++++++++++++ .../services/shearwater_filename_parser.dart | 45 +++ .../services/shearwater_value_mapper.dart | 110 +++++++ .../parsers/shearwater_cloud_parser_test.dart | 96 ++++++ .../services/shearwater_db_reader_test.dart | 83 +++++ .../shearwater_filename_parser_test.dart | 78 +++++ .../shearwater_value_mapper_test.dart | 93 ++++++ 8 files changed, 895 insertions(+) create mode 100644 lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart create mode 100644 lib/features/universal_import/data/services/shearwater_db_reader.dart create mode 100644 lib/features/universal_import/data/services/shearwater_filename_parser.dart create mode 100644 lib/features/universal_import/data/services/shearwater_value_mapper.dart create mode 100644 test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart create mode 100644 test/features/universal_import/data/services/shearwater_db_reader_test.dart create mode 100644 test/features/universal_import/data/services/shearwater_filename_parser_test.dart create mode 100644 test/features/universal_import/data/services/shearwater_value_mapper_test.dart diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart new file mode 100644 index 000000000..edc64d362 --- /dev/null +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -0,0 +1,88 @@ +import 'dart:typed_data'; + +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +/// Parses a Shearwater Cloud SQLite database file into an [ImportPayload]. +/// +/// Orchestrates the full Shearwater Cloud import flow: +/// 1. Validates the database structure. +/// 2. Reads raw dives from [ShearwaterDbReader]. +/// 3. Maps each dive via [ShearwaterDiveMapper.mapDive]. +/// 4. Extracts unique sites via [ShearwaterDiveMapper.mapSites]. +/// 5. Returns a unified [ImportPayload]. +class ShearwaterCloudParser implements ImportParser { + @override + List get supportedFormats => [ImportFormat.shearwaterDb]; + + @override + Future parse( + Uint8List fileBytes, { + ImportOptions? options, + }) async { + // 1. Validate database. + final isValid = await ShearwaterDbReader.isShearwaterCloudDb(fileBytes); + if (!isValid) { + return const ImportPayload( + entities: {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: + 'File is not a valid Shearwater Cloud database. ' + 'Expected dive_details and log_data tables.', + ), + ], + ); + } + + // 2. Read raw dives. + final rawDives = await ShearwaterDbReader.readDives(fileBytes); + if (rawDives.isEmpty) { + return const ImportPayload( + entities: {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.info, + message: 'Shearwater Cloud database contains no dives.', + ), + ], + ); + } + + // 3. Map dives to ImportPayload entities. + final warnings = []; + final diveEntities = >[]; + + for (final rawDive in rawDives) { + final diveMap = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + diveEntities.add(diveMap); + } + + // 4. Extract unique sites. + final sites = ShearwaterDiveMapper.mapSites(rawDives); + + // 5. Build payload. + final entities = >>{}; + if (diveEntities.isNotEmpty) { + entities[ImportEntityType.dives] = diveEntities; + } + if (sites.isNotEmpty) { + entities[ImportEntityType.sites] = sites; + } + + return ImportPayload( + entities: entities, + warnings: warnings, + metadata: {'source': 'shearwater_cloud', 'diveCount': rawDives.length}, + ); + } +} diff --git a/lib/features/universal_import/data/services/shearwater_db_reader.dart b/lib/features/universal_import/data/services/shearwater_db_reader.dart new file mode 100644 index 000000000..0c9982b66 --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_db_reader.dart @@ -0,0 +1,302 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:sqlite3/sqlite3.dart'; + +/// Raw dive data read directly from a Shearwater Cloud SQLite database. +/// +/// Fields map 1:1 to columns from the dive_details and log_data tables. +/// Binary log data is decompressed; JSON string columns are pre-parsed. +/// Empty strings from the database are normalized to null. +class ShearwaterRawDive { + final String diveId; + final String? diveDate; + final double? depth; + final double? averageDepth; + final int? diveLengthTime; + final String? diveNumber; + final String? serialNumber; + final String? location; + final String? site; + final String? buddy; + final String? notes; + final String? environment; + final String? visibility; + final String? weather; + final String? conditions; + final String? airTemperature; + final String? weight; + final String? dress; + final String? apparatus; + final String? thermalComfort; + final String? workload; + final String? problems; + final String? malfunctions; + final String? symptoms; + final String? gnssEntryLocation; + final String? gnssExitLocation; + final String? gasNotes; + final String? gearNotes; + final String? issueNotes; + final double? endGF99; + final String? fileName; + final Uint8List? decompressedLogData; + final Map? tankProfileData; + final Map? calculatedValues; + final Map? headerJson; + final Map? footerJson; + + const ShearwaterRawDive({ + required this.diveId, + this.diveDate, + this.depth, + this.averageDepth, + this.diveLengthTime, + this.diveNumber, + this.serialNumber, + this.location, + this.site, + this.buddy, + this.notes, + this.environment, + this.visibility, + this.weather, + this.conditions, + this.airTemperature, + this.weight, + this.dress, + this.apparatus, + this.thermalComfort, + this.workload, + this.problems, + this.malfunctions, + this.symptoms, + this.gnssEntryLocation, + this.gnssExitLocation, + this.gasNotes, + this.gearNotes, + this.issueNotes, + this.endGF99, + this.fileName, + this.decompressedLogData, + this.tankProfileData, + this.calculatedValues, + this.headerJson, + this.footerJson, + }); +} + +/// Reads dives from a Shearwater Cloud SQLite database. +/// +/// The Shearwater Cloud app exports its dive log as a SQLite database +/// with two primary tables: dive_details (metadata) and log_data (binary +/// dive profile data). This reader validates the database structure, +/// queries both tables, decompresses the binary BLOBs, and parses the +/// embedded JSON fields. +class ShearwaterDbReader { + static const _requiredTables = ['dive_details', 'log_data']; + + static const _query = ''' +SELECT dd.DiveId, dd.DiveDate, dd.Depth, dd.AverageDepth, + dd.DiveLengthTime, dd.DiveNumber, dd.SerialNumber, + dd.Location, dd.Site, dd.Buddy, dd.Notes, + dd.Environment, dd.Visibility, dd.Weather, dd.Conditions, + dd.AirTemperature, dd.Weight, dd.Dress, dd.Apparatus, + dd.ThermalComfort, dd.Workload, dd.Problems, + dd.Malfunctions, dd.Symptoms, + dd.GnssEntryLocation, dd.GnssExitLocation, + dd.TankProfileData, + dd.GasNotes, dd.GearNotes, dd.IssueNotes, dd.EndGF99, + ld.file_name, ld.data_bytes_1, ld.data_bytes_2, + ld.data_bytes_3, ld.calculated_values_from_samples +FROM dive_details dd +LEFT JOIN log_data ld ON dd.DiveId = ld.log_id +ORDER BY dd.DiveDate +'''; + + /// Returns true if the given bytes represent a Shearwater Cloud database. + /// + /// Writes the bytes to a temporary file and opens it as SQLite. Checks + /// for the presence of the required tables (dive_details, log_data). + /// Returns false for any non-SQLite file or database missing those tables. + static Future isShearwaterCloudDb(Uint8List bytes) async { + final tempPath = _tempPath(); + final tempFile = File(tempPath); + try { + await tempFile.writeAsBytes(bytes); + final db = sqlite3.open(tempPath, mode: OpenMode.readOnly); + try { + final tables = _listTables(db); + return _requiredTables.every((t) => tables.contains(t)); + } finally { + db.dispose(); + } + } catch (_) { + return false; + } finally { + _deleteTempFile(tempFile); + } + } + + /// Reads all dives from the Shearwater Cloud database. + /// + /// Joins dive_details with log_data, decompresses binary profile data, + /// and parses embedded JSON fields. Empty strings are normalized to null. + static Future> readDives(Uint8List bytes) async { + final tempPath = _tempPath(); + final tempFile = File(tempPath); + try { + await tempFile.writeAsBytes(bytes); + final db = sqlite3.open(tempPath, mode: OpenMode.readOnly); + try { + final rows = db.select(_query); + return rows.map(_rowToRawDive).toList(); + } finally { + db.dispose(); + } + } finally { + _deleteTempFile(tempFile); + } + } + + // ======================== Internal helpers ======================== + + static String _tempPath() { + return '${Directory.systemTemp.path}' + '/sw_import_${DateTime.now().millisecondsSinceEpoch}.db'; + } + + static void _deleteTempFile(File file) { + try { + if (file.existsSync()) file.deleteSync(); + } catch (_) { + // Best-effort cleanup; ignore errors. + } + } + + static Set _listTables(Database db) { + final rows = db.select("SELECT name FROM sqlite_master WHERE type='table'"); + return rows.map((r) => r['name'] as String).toSet(); + } + + static ShearwaterRawDive _rowToRawDive(Row row) { + return ShearwaterRawDive( + diveId: row['DiveId'].toString(), + diveDate: _str(row['DiveDate']), + depth: _double(row['Depth']), + averageDepth: _double(row['AverageDepth']), + diveLengthTime: _int(row['DiveLengthTime']), + diveNumber: _str(row['DiveNumber']), + serialNumber: _str(row['SerialNumber']), + location: _str(row['Location']), + site: _str(row['Site']), + buddy: _str(row['Buddy']), + notes: _str(row['Notes']), + environment: _str(row['Environment']), + visibility: _str(row['Visibility']), + weather: _str(row['Weather']), + conditions: _str(row['Conditions']), + airTemperature: _str(row['AirTemperature']), + weight: _str(row['Weight']), + dress: _str(row['Dress']), + apparatus: _str(row['Apparatus']), + thermalComfort: _str(row['ThermalComfort']), + workload: _str(row['Workload']), + problems: _str(row['Problems']), + malfunctions: _str(row['Malfunctions']), + symptoms: _str(row['Symptoms']), + gnssEntryLocation: _str(row['GnssEntryLocation']), + gnssExitLocation: _str(row['GnssExitLocation']), + gasNotes: _str(row['GasNotes']), + gearNotes: _str(row['GearNotes']), + issueNotes: _str(row['IssueNotes']), + endGF99: _double(row['EndGF99']), + fileName: _str(row['file_name']), + decompressedLogData: _decompressDataBytes1(row['data_bytes_1']), + headerJson: _decodeJsonBlob(row['data_bytes_2']), + footerJson: _decodeJsonBlob(row['data_bytes_3']), + tankProfileData: _decodeJsonString(row['TankProfileData']), + calculatedValues: _decodeJsonString( + row['calculated_values_from_samples'], + ), + ); + } + + /// Normalizes a value to a non-empty String or null. + static String? _str(dynamic value) { + if (value == null) return null; + final s = value.toString(); + return s.isEmpty ? null : s; + } + + static double? _double(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + static int? _int(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()); + } + + /// Decompresses data_bytes_1: skip first 4 bytes, gzip decompress the rest. + static Uint8List? _decompressDataBytes1(dynamic value) { + if (value == null) return null; + final Uint8List raw; + if (value is Uint8List) { + raw = value; + } else if (value is List) { + raw = Uint8List.fromList(value); + } else { + return null; + } + if (raw.length <= 4) return null; + try { + final gzipBytes = raw.sublist(4); + final decompressed = GZipCodec().decode(gzipBytes); + return Uint8List.fromList(decompressed); + } catch (_) { + return null; + } + } + + /// Decodes data_bytes_2 and data_bytes_3: UTF-8 decode the BLOB, then JSON + /// parse. + static Map? _decodeJsonBlob(dynamic value) { + if (value == null) return null; + try { + final String text; + if (value is Uint8List) { + text = utf8.decode(value); + } else if (value is List) { + text = utf8.decode(value); + } else { + text = value.toString(); + } + if (text.isEmpty) return null; + final decoded = jsonDecode(text); + if (decoded is Map) return decoded; + return null; + } catch (_) { + return null; + } + } + + /// Parses a JSON string column (TankProfileData, calculated_values_from_samples). + static Map? _decodeJsonString(dynamic value) { + if (value == null) return null; + final s = value.toString(); + if (s.isEmpty) return null; + try { + final decoded = jsonDecode(s); + if (decoded is Map) return decoded; + return null; + } catch (_) { + return null; + } + } +} diff --git a/lib/features/universal_import/data/services/shearwater_filename_parser.dart b/lib/features/universal_import/data/services/shearwater_filename_parser.dart new file mode 100644 index 000000000..68d7f5355 --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_filename_parser.dart @@ -0,0 +1,45 @@ +/// Parsed result from a Shearwater Cloud log_data filename. +class ShearwaterFilenameInfo { + final String? model; + final String? serial; + final int? diveNumber; + + const ShearwaterFilenameInfo({this.model, this.serial, this.diveNumber}); +} + +/// Parses Shearwater Cloud log_data filenames to extract model, serial, and dive number. +/// +/// Filename format: "ModelName[HexSerial]#DiveNum YYYY-M-D H-M-S.swlogzp" +class ShearwaterFilenameParser { + static final _pattern = RegExp(r'^(.+?)\[([A-Fa-f0-9]+)\]#(\d+)\s'); + + static const _knownModels = { + 'Teric': ('Shearwater', 'Teric'), + 'Perdix': ('Shearwater', 'Perdix'), + 'Perdix 2': ('Shearwater', 'Perdix 2'), + 'Perdix AI': ('Shearwater', 'Perdix AI'), + 'Peregrine': ('Shearwater', 'Peregrine'), + 'Petrel': ('Shearwater', 'Petrel'), + 'Petrel 2': ('Shearwater', 'Petrel 2'), + 'Petrel 3': ('Shearwater', 'Petrel 3'), + 'Tern': ('Shearwater', 'Tern'), + 'NERD': ('Shearwater', 'NERD'), + 'NERD 2': ('Shearwater', 'NERD 2'), + }; + + static ShearwaterFilenameInfo parse(String filename) { + final match = _pattern.firstMatch(filename); + if (match == null) { + return const ShearwaterFilenameInfo(); + } + return ShearwaterFilenameInfo( + model: match.group(1), + serial: match.group(2), + diveNumber: int.tryParse(match.group(3) ?? ''), + ); + } + + static (String, String)? vendorProduct(String model) { + return _knownModels[model]; + } +} diff --git a/lib/features/universal_import/data/services/shearwater_value_mapper.dart b/lib/features/universal_import/data/services/shearwater_value_mapper.dart new file mode 100644 index 000000000..91a0ad53d --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_value_mapper.dart @@ -0,0 +1,110 @@ +import 'package:submersion/core/constants/enums.dart'; + +/// Converts Shearwater Cloud field values to Submersion's metric system +/// and maps condition strings to Submersion enums. +class ShearwaterValueMapper { + // --------------------------------------------------------------------------- + // Unit conversions + // --------------------------------------------------------------------------- + + static double psiToBar(num psi) => psi / 14.5038; + + static double fahrenheitToCelsius(num f) => (f - 32) * 5 / 9; + + static double lbsToKg(num lbs) => lbs * 0.453592; + + static double feetToMeters(num feet) => feet * 0.3048; + + static double mbarToBar(num mbar) => mbar / 1000; + + // --------------------------------------------------------------------------- + // Conditions mapping + // --------------------------------------------------------------------------- + + static WaterType? mapWaterType(String? environment) { + if (environment == null || environment.isEmpty) return null; + return switch (environment) { + 'Ocean/Sea' => WaterType.salt, + 'Pool' || 'Lake' || 'Quarry' || 'River' => WaterType.fresh, + 'Brackish' => WaterType.brackish, + _ => null, + }; + } + + static CloudCover? mapCloudCover(String? weather) { + if (weather == null || weather.isEmpty) return null; + return switch (weather) { + 'Sunny' || 'Clear' => CloudCover.clear, + 'Partly Cloudy' => CloudCover.partlyCloudy, + 'Cloudy' || 'Overcast' => CloudCover.mostlyCloudy, + _ => null, + }; + } + + static CurrentStrength? mapCurrentStrength(String? conditions) { + if (conditions == null || conditions.isEmpty) return null; + return switch (conditions) { + 'Current' => CurrentStrength.moderate, + 'Strong Current' => CurrentStrength.strong, + 'Light Current' => CurrentStrength.light, + _ => null, + }; + } + + /// Maps a visibility string value to a [Visibility] enum. + /// + /// [value] is the raw string (e.g. '100'). + /// [isImperial] indicates whether [value] is in feet (true) or meters (false). + static Visibility? mapVisibility(String? value, {bool isImperial = true}) { + if (value == null || value.isEmpty) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + final meters = isImperial ? feetToMeters(numValue) : numValue; + if (meters >= 30) return Visibility.excellent; + if (meters >= 15) return Visibility.good; + if (meters >= 5) return Visibility.moderate; + return Visibility.poor; + } + + // --------------------------------------------------------------------------- + // Extra notes builder + // --------------------------------------------------------------------------- + + /// Collects unmapped Shearwater Cloud fields into a structured notes string. + /// + /// Weather and conditions values are only included when they cannot be mapped + /// to a structured enum (i.e. they fall through to null). Returns null when + /// there are no extra fields to record. + static String? buildExtraNotes({ + String? weather, + String? conditions, + String? dress, + String? thermalComfort, + String? workload, + String? problems, + String? malfunctions, + String? symptoms, + String? gasNotes, + String? gearNotes, + String? issueNotes, + }) { + final entries = []; + if (weather != null && mapCloudCover(weather) == null) { + entries.add('Weather: $weather'); + } + if (conditions != null && mapCurrentStrength(conditions) == null) { + entries.add('Conditions: $conditions'); + } + if (dress != null) entries.add('Dress: $dress'); + if (thermalComfort != null) entries.add('Thermal Comfort: $thermalComfort'); + if (workload != null) entries.add('Workload: $workload'); + if (problems != null) entries.add('Problems: $problems'); + if (malfunctions != null) entries.add('Malfunctions: $malfunctions'); + if (symptoms != null) entries.add('Symptoms: $symptoms'); + if (gasNotes != null) entries.add('Gas Notes: $gasNotes'); + if (gearNotes != null) entries.add('Gear Notes: $gearNotes'); + if (issueNotes != null) entries.add('Issue Notes: $issueNotes'); + if (entries.isEmpty) return null; + return '[Shearwater Cloud]\n${entries.join('\n')}'; + } +} diff --git a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart new file mode 100644 index 000000000..05e29c8aa --- /dev/null +++ b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; + +void main() { + late Uint8List dbBytes; + + setUpAll(() { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + fail('Test fixture not found: third_party/shearwater_cloud_database.db'); + } + dbBytes = file.readAsBytesSync(); + }); + + group('ShearwaterCloudParser', () { + test('supportedFormats includes shearwaterDb', () { + final parser = ShearwaterCloudParser(); + expect(parser.supportedFormats, contains(ImportFormat.shearwaterDb)); + }); + + test('parse returns payload with 28 dives', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + + expect(payload.isNotEmpty, isTrue); + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(28)); + }); + + test('parse includes sites', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + + final sites = payload.entitiesOf(ImportEntityType.sites); + expect(sites, isNotEmpty); + }); + + test('dive entities have required fields', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + + final dives = payload.entitiesOf(ImportEntityType.dives); + final dive = dives.firstWhere( + (d) => d['importId'] == '1676633251758354277', + ); + expect(dive['dateTime'], isA()); + expect(dive['maxDepth'], isA()); + expect(dive['importSource'], 'shearwater_cloud'); + expect(dive['notes'], contains('PADI Open Water')); + }); + + test('parse returns empty payload for non-Shearwater bytes', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(Uint8List.fromList([1, 2, 3])); + expect(payload.isEmpty, isTrue); + expect(payload.warnings, isNotEmpty); + }); + + test('metadata contains source and dive count', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + + expect(payload.metadata['source'], 'shearwater_cloud'); + expect(payload.metadata['diveCount'], 28); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_db_reader_test.dart b/test/features/universal_import/data/services/shearwater_db_reader_test.dart new file mode 100644 index 000000000..ef014db27 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_db_reader_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; + +void main() { + late Uint8List dbBytes; + + setUpAll(() { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + fail('Test fixture not found: third_party/shearwater_cloud_database.db'); + } + dbBytes = file.readAsBytesSync(); + }); + + group('ShearwaterDbReader', () { + test('isShearwaterCloudDb returns true for valid database', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb(dbBytes); + expect(result, isTrue); + }); + + test('isShearwaterCloudDb returns false for non-SQLite bytes', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb( + Uint8List.fromList([1, 2, 3, 4]), + ); + expect(result, isFalse); + }); + + test('readDives returns all dives from database', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + expect(dives, hasLength(28)); + }); + + test('dive has metadata from dive_details', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.firstWhere((d) => d.diveId == '1676633251758354277'); + expect(dive.location, 'Shark River, NJ, USA'); + expect(dive.site, 'Maclearie Park'); + expect(dive.buddy, 'Kiyan Griffin'); + expect(dive.notes, 'PADI Open Water certification dive 1'); + expect(dive.environment, 'Ocean/Sea'); + }); + + test('dive has binary data decompressed from log_data', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.first; + expect(dive.decompressedLogData, isNotNull); + expect(dive.decompressedLogData, isNotEmpty); + }); + + test('dive has filename from log_data', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.first; + expect(dive.fileName, contains('Teric')); + expect(dive.fileName, contains('.swlogzp')); + }); + + test('dive has TankProfileData parsed as JSON', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.first; + expect(dive.tankProfileData, isNotNull); + expect(dive.tankProfileData!['GasProfiles'], isList); + expect(dive.tankProfileData!['TankData'], isList); + }); + + test('dive has calculatedValues from log_data', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.first; + expect(dive.calculatedValues, isNotNull); + expect(dive.calculatedValues!['AverageDepth'], isA()); + }); + + test('dive has footer JSON from data_bytes_3', () async { + final dives = await ShearwaterDbReader.readDives(dbBytes); + final dive = dives.first; + expect(dive.footerJson, isNotNull); + expect(dive.footerJson!['UnitSystem'], isA()); + expect(dive.footerJson!['DiveTimeInSeconds'], isA()); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_filename_parser_test.dart b/test/features/universal_import/data/services/shearwater_filename_parser_test.dart new file mode 100644 index 000000000..efd3749cd --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_filename_parser_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_filename_parser.dart'; + +void main() { + group('ShearwaterFilenameParser', () { + group('parseFilename', () { + test('extracts Teric model and serial', () { + final result = ShearwaterFilenameParser.parse( + 'Teric[8629AC48]#1 2025-9-20 7-42-35.swlogzp', + ); + expect(result.model, 'Teric'); + expect(result.serial, '8629AC48'); + expect(result.diveNumber, 1); + }); + + test('extracts Perdix model and serial', () { + final result = ShearwaterFilenameParser.parse( + 'Perdix[ABCD1234]#15 2025-12-01 10-30-00.swlogzp', + ); + expect(result.model, 'Perdix'); + expect(result.serial, 'ABCD1234'); + expect(result.diveNumber, 15); + }); + + test('extracts Petrel 3 with space in name', () { + final result = ShearwaterFilenameParser.parse( + 'Petrel 3[11223344]#7 2025-6-15 14-00-00.swlogzp', + ); + expect(result.model, 'Petrel 3'); + expect(result.serial, '11223344'); + expect(result.diveNumber, 7); + }); + + test('extracts Peregrine', () { + final result = ShearwaterFilenameParser.parse( + 'Peregrine[DEADBEEF]#100 2025-1-1 0-0-0.swlogzp', + ); + expect(result.model, 'Peregrine'); + expect(result.serial, 'DEADBEEF'); + expect(result.diveNumber, 100); + }); + + test('returns unknown for unrecognized format', () { + final result = ShearwaterFilenameParser.parse('random_file.db'); + expect(result.model, isNull); + expect(result.serial, isNull); + expect(result.diveNumber, isNull); + }); + + test('returns unknown for empty string', () { + final result = ShearwaterFilenameParser.parse(''); + expect(result.model, isNull); + expect(result.serial, isNull); + }); + }); + + group('vendorProduct', () { + test('maps known models to vendor/product', () { + expect(ShearwaterFilenameParser.vendorProduct('Teric'), ( + 'Shearwater', + 'Teric', + )); + expect(ShearwaterFilenameParser.vendorProduct('Perdix'), ( + 'Shearwater', + 'Perdix', + )); + expect(ShearwaterFilenameParser.vendorProduct('Petrel 3'), ( + 'Shearwater', + 'Petrel 3', + )); + }); + + test('returns null for unknown model', () { + expect(ShearwaterFilenameParser.vendorProduct('Unknown'), isNull); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_value_mapper_test.dart b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart new file mode 100644 index 000000000..3f32225e6 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_value_mapper.dart'; + +void main() { + group('ShearwaterValueMapper', () { + group('unit conversions', () { + test('converts PSI to bar', () { + expect(ShearwaterValueMapper.psiToBar(2960), closeTo(204.1, 0.1)); + }); + test('converts Fahrenheit to Celsius', () { + expect( + ShearwaterValueMapper.fahrenheitToCelsius(72), + closeTo(22.2, 0.1), + ); + expect(ShearwaterValueMapper.fahrenheitToCelsius(32), closeTo(0, 0.1)); + }); + test('converts lbs to kg', () { + expect(ShearwaterValueMapper.lbsToKg(14), closeTo(6.35, 0.01)); + }); + test('converts feet to meters', () { + expect(ShearwaterValueMapper.feetToMeters(30), closeTo(9.14, 0.01)); + }); + test('converts mbar to bar', () { + expect(ShearwaterValueMapper.mbarToBar(1015), closeTo(1.015, 0.001)); + }); + }); + + group('conditions mapping', () { + test('maps environment to waterType', () { + expect(ShearwaterValueMapper.mapWaterType('Ocean/Sea'), WaterType.salt); + expect(ShearwaterValueMapper.mapWaterType('Pool'), WaterType.fresh); + expect(ShearwaterValueMapper.mapWaterType('Lake'), WaterType.fresh); + expect(ShearwaterValueMapper.mapWaterType(null), isNull); + expect(ShearwaterValueMapper.mapWaterType(''), isNull); + }); + test('maps weather to cloudCover', () { + expect(ShearwaterValueMapper.mapCloudCover('Sunny'), CloudCover.clear); + expect( + ShearwaterValueMapper.mapCloudCover('Cloudy'), + CloudCover.mostlyCloudy, + ); + expect(ShearwaterValueMapper.mapCloudCover('Windy'), isNull); + }); + test('maps conditions to currentStrength', () { + expect( + ShearwaterValueMapper.mapCurrentStrength('Current'), + CurrentStrength.moderate, + ); + expect(ShearwaterValueMapper.mapCurrentStrength('Surge'), isNull); + expect(ShearwaterValueMapper.mapCurrentStrength(null), isNull); + }); + test('maps visibility to enum', () { + expect( + ShearwaterValueMapper.mapVisibility('100', isImperial: true), + Visibility.excellent, + ); + expect( + ShearwaterValueMapper.mapVisibility('30', isImperial: true), + Visibility.moderate, + ); + expect( + ShearwaterValueMapper.mapVisibility('10', isImperial: true), + Visibility.poor, + ); + expect( + ShearwaterValueMapper.mapVisibility('30', isImperial: false), + Visibility.excellent, + ); + expect(ShearwaterValueMapper.mapVisibility(null), isNull); + }); + }); + + group('buildExtraNotes', () { + test('collects unmapped fields into structured notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + weather: 'Windy', + conditions: 'Surge', + dress: 'Wet Suit', + thermalComfort: 'Warm/Neutral', + workload: 'Resting', + ); + expect(notes, contains('[Shearwater Cloud]')); + expect(notes, contains('Weather: Windy')); + expect(notes, contains('Dress: Wet Suit')); + }); + test('returns null when no extra fields present', () { + final notes = ShearwaterValueMapper.buildExtraNotes(); + expect(notes, isNull); + }); + }); + }); +} From ad0374814f727000e74ee4c004ec17b1477ffd6c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 17:18:43 -0400 Subject: [PATCH 05/12] fix: resolve Shearwater profile parsing - model=0 wildcard + richer samples Two issues preventing dive profile import from Shearwater Cloud DBs: 1. find_descriptor() required exact model match. Passing model=0 from Dart caused descriptor lookup to fail silently. Now treats model=0 as wildcard, matching by vendor+product only. 2. Profile samples only included depth/temp/pressure. Now includes all available sensor data: ppO2, setpoint, heartRate, CNS, RBT, TTS, decoType, ceiling (decoDepth), NDL (decoTime). Also overrides diveMode from parsed data instead of guessing from apparatus string. --- .../data/services/shearwater_dive_mapper.dart | 38 ++++++++++++++++++- .../macos/Classes/libdc_download.c | 3 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart index e5d6eab4f..4df8937da 100644 --- a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -325,7 +325,16 @@ class ShearwaterDiveMapper { merged['gradientFactorHigh'] = parsed.gfHigh; } - // Build profile samples + // Override dive mode from parsed data (more accurate than apparatus guess). + if (parsed.diveMode != null) { + merged['diveMode'] = switch (parsed.diveMode) { + 'ccr' => DiveMode.ccr, + 'scr' => DiveMode.scr, + _ => DiveMode.oc, + }; + } + + // Build profile samples with all available sensor data. merged['profile'] = parsed.samples.map((s) { final sampleMap = { 'timestamp': s.timeSeconds, @@ -337,6 +346,33 @@ class ShearwaterDiveMapper { if (s.pressureBar != null) { sampleMap['pressure'] = s.pressureBar; } + if (s.setpoint != null) { + sampleMap['setpoint'] = s.setpoint; + } + if (s.ppo2 != null) { + sampleMap['ppO2'] = s.ppo2; + } + if (s.heartRate != null) { + sampleMap['heartRate'] = s.heartRate; + } + if (s.cns != null) { + sampleMap['cns'] = s.cns; + } + if (s.rbt != null) { + sampleMap['rbt'] = s.rbt; + } + if (s.tts != null) { + sampleMap['tts'] = s.tts; + } + if (s.decoType != null) { + sampleMap['decoType'] = s.decoType; + } + if (s.decoDepth != null) { + sampleMap['ceiling'] = s.decoDepth; + } + if (s.decoTime != null) { + sampleMap['ndl'] = s.decoTime; + } return sampleMap; }).toList(); diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c index 18605f8b9..5dd37ee15 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c @@ -76,7 +76,8 @@ static dc_descriptor_t *find_descriptor(const char *vendor, const char *product, const char *p = dc_descriptor_get_product(desc); unsigned int m = dc_descriptor_get_model(desc); if (v != NULL && p != NULL && - strcmp(v, vendor) == 0 && strcmp(p, product) == 0 && m == model) { + strcmp(v, vendor) == 0 && strcmp(p, product) == 0 && + (model == 0 || m == model)) { match = desc; break; } From de45b22deb69fabeac5a59d7c5f2ec5312fbec8c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 27 Mar 2026 23:58:00 -0400 Subject: [PATCH 06/12] test: increase Shearwater import coverage to 90%+ Make mergeWithParsedDive @visibleForTesting and add 16 new tests covering: profile merge with all sensor fields, deco model/GF, dive mode mapping, water temp extraction, unknown model fallback, brackish water type, gasNotes/issueNotes in buildExtraNotes. --- .../data/services/shearwater_dive_mapper.dart | 27 +- .../services/shearwater_dive_mapper_test.dart | 383 ++++++++++++++++++ .../shearwater_value_mapper_test.dart | 24 ++ 3 files changed, 428 insertions(+), 6 deletions(-) diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart index 4df8937da..4864c18de 100644 --- a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; @@ -101,19 +102,32 @@ class ShearwaterDiveMapper { } try { + debugPrint( + '[ShearwaterDiveMapper] Attempting FFI parse: ' + '${vendorProduct.$1} ${vendorProduct.$2}, ' + '${logData.length} bytes', + ); final parsed = await _parseWithFfi( vendor: vendorProduct.$1, product: vendorProduct.$2, data: logData, ); - return _mergeWithParsedDive(baseMap, parsed); - } catch (_) { + debugPrint( + '[ShearwaterDiveMapper] FFI success: ' + '${parsed.samples.length} samples, ' + 'depth=${parsed.maxDepthMeters}m', + ); + return mergeWithParsedDive(baseMap, parsed); + } catch (e, stack) { + debugPrint( + '[ShearwaterDiveMapper] FFI parse failed for ' + 'dive ${rawDive.diveId}: $e', + ); + debugPrint('[ShearwaterDiveMapper] Stack: $stack'); warnings?.add( ImportWarning( severity: ImportWarningSeverity.warning, - message: - 'Profile parsing failed for dive ${rawDive.diveId}; ' - 'using metadata only', + message: 'Profile parsing failed for dive ${rawDive.diveId}: $e', entityType: ImportEntityType.dives, ), ); @@ -303,7 +317,8 @@ class ShearwaterDiveMapper { } /// Merges parsed profile and deco data into the base metadata map. - static Map _mergeWithParsedDive( + @visibleForTesting + static Map mergeWithParsedDive( Map baseMap, pigeon.ParsedDive parsed, ) { diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart index ec2e7b011..6c0f99b51 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; @@ -508,6 +510,387 @@ void main() { expect(warnings, hasLength(1)); expect(warnings[0].severity, ImportWarningSeverity.warning); }); + + test('returns metadata only when model is unknown', () async { + final rawDive = ShearwaterRawDive( + diveId: 'test-unknown', + fileName: 'UnknownModel[ABCD]#1 2025-1-1 0-0-0.swlogzp', + decompressedLogData: Uint8List.fromList([1, 2, 3]), + ); + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + expect(result['profile'], isEmpty); + expect(warnings, isNotEmpty); + expect(warnings.first.message, contains('Could not determine')); + expect(warnings.first.severity, ImportWarningSeverity.warning); + expect(warnings.first.entityType, ImportEntityType.dives); + }); + }); + + group('mergeWithParsedDive', () { + test('overrides depth/duration from parsed data', () { + final baseMap = { + 'maxDepth': 10.0, + 'avgDepth': 5.0, + 'runtime': const Duration(seconds: 100), + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: 'abc', + dateTimeYear: 2025, + dateTimeMonth: 12, + dateTimeDay: 27, + dateTimeHour: 14, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 26.8, + avgDepthMeters: 19.4, + durationSeconds: 1764, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['maxDepth'], 26.8); + expect(result['avgDepth'], 19.4); + expect((result['runtime'] as Duration).inSeconds, 1764); + }); + + test('adds deco algorithm and GF from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + decoAlgorithm: 'buhlmann', + gfLow: 30, + gfHigh: 70, + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['decoAlgorithm'], 'buhlmann'); + expect(result['gradientFactorLow'], 30); + expect(result['gradientFactorHigh'], 70); + }); + + test('does not add deco fields when absent in parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result.containsKey('decoAlgorithm'), isFalse); + expect(result.containsKey('gradientFactorLow'), isFalse); + expect(result.containsKey('gradientFactorHigh'), isFalse); + }); + + test('maps dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'ccr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.ccr); + }); + + test('maps scr dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'scr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.scr); + }); + + test('maps unknown dive mode to oc', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'gauge', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.oc); + }); + + test('builds profile samples with all sensor data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 20, + avgDepthMeters: 10, + durationSeconds: 600, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + pressureBar: 200.0, + setpoint: 1.3, + ppo2: 1.1, + heartRate: 80, + cns: 5.0, + rbt: 60, + tts: 120, + decoType: 0, + decoTime: 99, + decoDepth: 3.0, + ), + pigeon.ProfileSample(timeSeconds: 20, depthMeters: 10.0), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + final profile = result['profile'] as List; + expect(profile, hasLength(2)); + + final s1 = profile[0] as Map; + expect(s1['timestamp'], 10); + expect(s1['depth'], 5.0); + expect(s1['temperature'], 22.0); + expect(s1['pressure'], 200.0); + expect(s1['setpoint'], 1.3); + expect(s1['ppO2'], 1.1); + expect(s1['heartRate'], 80); + expect(s1['cns'], 5.0); + expect(s1['rbt'], 60); + expect(s1['tts'], 120); + expect(s1['decoType'], 0); + expect(s1['ceiling'], 3.0); + expect(s1['ndl'], 99); + + // Second sample has only depth -- no optional fields + final s2 = profile[1] as Map; + expect(s2['timestamp'], 20); + expect(s2['depth'], 10.0); + expect(s2.containsKey('temperature'), isFalse); + expect(s2.containsKey('pressure'), isFalse); + expect(s2.containsKey('setpoint'), isFalse); + expect(s2.containsKey('ppO2'), isFalse); + expect(s2.containsKey('heartRate'), isFalse); + expect(s2.containsKey('cns'), isFalse); + expect(s2.containsKey('rbt'), isFalse); + expect(s2.containsKey('tts'), isFalse); + expect(s2.containsKey('decoType'), isFalse); + expect(s2.containsKey('ceiling'), isFalse); + expect(s2.containsKey('ndl'), isFalse); + }); + + test('extracts water temp from samples when not in metadata', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + ), + pigeon.ProfileSample( + timeSeconds: 20, + depthMeters: 8.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 20.0); // min temperature + }); + + test('does not override existing waterTemp', () { + final baseMap = { + 'waterTemp': 25.0, + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 25.0); // unchanged + }); + + test('does not set waterTemp when no temperature samples exist', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [pigeon.ProfileSample(timeSeconds: 10, depthMeters: 5.0)], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], isNull); + }); + }); + + group('_toDouble fallback', () { + test('string-to-double fallback via mapTanks', () { + // _toDouble is private but exercised through mapTanks when + // GasProfile values are strings instead of nums. + final rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': '32.0', 'HePercent': '10.0'}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32.0); + expect((tanks[0]['gasMix'] as GasMix).he, 10.0); + }); }); }); } diff --git a/test/features/universal_import/data/services/shearwater_value_mapper_test.dart b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart index 3f32225e6..dd535b4f6 100644 --- a/test/features/universal_import/data/services/shearwater_value_mapper_test.dart +++ b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart @@ -34,6 +34,12 @@ void main() { expect(ShearwaterValueMapper.mapWaterType(null), isNull); expect(ShearwaterValueMapper.mapWaterType(''), isNull); }); + test('maps Brackish to WaterType.brackish', () { + expect( + ShearwaterValueMapper.mapWaterType('Brackish'), + WaterType.brackish, + ); + }); test('maps weather to cloudCover', () { expect(ShearwaterValueMapper.mapCloudCover('Sunny'), CloudCover.clear); expect( @@ -88,6 +94,24 @@ void main() { final notes = ShearwaterValueMapper.buildExtraNotes(); expect(notes, isNull); }); + test('includes gasNotes in extra notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes(gasNotes: 'EAN32'); + expect(notes, contains('Gas Notes: EAN32')); + }); + test('includes issueNotes in extra notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + issueNotes: 'Mask fog', + ); + expect(notes, contains('Issue Notes: Mask fog')); + }); + test('includes both gasNotes and issueNotes together', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + gasNotes: 'EAN32', + issueNotes: 'Mask fog', + ); + expect(notes, contains('Gas Notes: EAN32')); + expect(notes, contains('Issue Notes: Mask fog')); + }); }); }); } From 0775d14275607f1a87de08d12230217050c5ad77 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 00:43:25 -0400 Subject: [PATCH 07/12] fix: handle Shearwater gzip streams with zeroed CRC trailers Some Shearwater Cloud databases produce gzip streams where the CRC32 and ISIZE trailer fields are zeroed out. Dart's GZipCodec validates these and rejects the data, while Python's gzip is lenient. Fix: try GZipCodec first, then fall back to raw deflate decompression by parsing the gzip header manually and using ZLibDecoder(raw: true). This bypasses trailer validation. Also adds debug logging for FFI parse attempts and decompression. --- .../data/parsers/shearwater_cloud_parser.dart | 9 +++ .../data/services/shearwater_db_reader.dart | 75 +++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart index edc64d362..10058763a 100644 --- a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; @@ -43,6 +44,14 @@ class ShearwaterCloudParser implements ImportParser { // 2. Read raw dives. final rawDives = await ShearwaterDbReader.readDives(fileBytes); + debugPrint('[ShearwaterCloudParser] Read ${rawDives.length} dives'); + for (final d in rawDives.take(3)) { + debugPrint( + '[ShearwaterCloudParser] Dive ${d.diveId}: ' + 'fileName=${d.fileName}, ' + 'logData=${d.decompressedLogData?.length ?? 0} bytes', + ); + } if (rawDives.isEmpty) { return const ImportPayload( entities: {}, diff --git a/lib/features/universal_import/data/services/shearwater_db_reader.dart b/lib/features/universal_import/data/services/shearwater_db_reader.dart index 0c9982b66..04a7aa2a6 100644 --- a/lib/features/universal_import/data/services/shearwater_db_reader.dart +++ b/lib/features/universal_import/data/services/shearwater_db_reader.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:sqlite3/sqlite3.dart'; /// Raw dive data read directly from a Shearwater Cloud SQLite database. @@ -243,7 +244,15 @@ ORDER BY dd.DiveDate return int.tryParse(value.toString()); } - /// Decompresses data_bytes_1: skip first 4 bytes, gzip decompress the rest. + /// Decompresses data_bytes_1: skip 4-byte length prefix, then decompress. + /// + /// Shearwater Cloud stores binary dive data as: + /// [4-byte LE decompressed size] [gzip stream] + /// + /// Some Shearwater Cloud databases produce gzip streams with zeroed-out + /// CRC32/ISIZE trailers, which Dart's strict [GZipCodec] rejects. We + /// try [GZipCodec] first, then fall back to raw deflate decompression + /// (skipping the 10-byte gzip header) which ignores the trailer. static Uint8List? _decompressDataBytes1(dynamic value) { if (value == null) return null; final Uint8List raw; @@ -254,16 +263,72 @@ ORDER BY dd.DiveDate } else { return null; } - if (raw.length <= 4) return null; + if (raw.length <= 14) return null; // 4 prefix + 10 gzip header minimum + + final gzipBytes = raw.sublist(4); + + // Fast path: standard GZipCodec (works when CRC/trailer are valid). try { - final gzipBytes = raw.sublist(4); - final decompressed = GZipCodec().decode(gzipBytes); - return Uint8List.fromList(decompressed); + return Uint8List.fromList(GZipCodec().decode(gzipBytes)); } catch (_) { + // Fall through to raw deflate. + } + + // Fallback: skip gzip header and decompress as raw deflate. + // This handles streams with zeroed-out CRC32/ISIZE trailers. + try { + final deflateStart = _gzipHeaderLength(gzipBytes); + if (deflateStart == null) return null; + final deflateData = gzipBytes.sublist(deflateStart); + final decoded = ZLibDecoder(raw: true).convert(deflateData); + return Uint8List.fromList(decoded); + } catch (e) { + debugPrint( + '[ShearwaterDbReader] decompress failed: $e ' + '(raw=${raw.length} bytes)', + ); return null; } } + /// Returns the byte offset where the deflate data starts in a gzip stream. + /// Returns null if the stream doesn't look like valid gzip. + static int? _gzipHeaderLength(Uint8List gz) { + if (gz.length < 10) return null; + if (gz[0] != 0x1F || gz[1] != 0x8B) return null; // not gzip magic + if (gz[2] != 0x08) return null; // not deflate method + + final flags = gz[3]; + var offset = 10; // minimum gzip header + + // FEXTRA + if (flags & 0x04 != 0) { + if (gz.length < offset + 2) return null; + final xlen = gz[offset] | (gz[offset + 1] << 8); + offset += 2 + xlen; + } + // FNAME + if (flags & 0x08 != 0) { + while (offset < gz.length && gz[offset] != 0) { + offset++; + } + offset++; // skip null terminator + } + // FCOMMENT + if (flags & 0x10 != 0) { + while (offset < gz.length && gz[offset] != 0) { + offset++; + } + offset++; + } + // FHCRC + if (flags & 0x02 != 0) { + offset += 2; + } + + return offset < gz.length ? offset : null; + } + /// Decodes data_bytes_2 and data_bytes_3: UTF-8 decode the BLOB, then JSON /// parse. static Map? _decodeJsonBlob(dynamic value) { From 3a55f71770572b1cb5e5f6502f2d2fc7ea1e0ed3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 01:20:52 -0400 Subject: [PATCH 08/12] style: fix analyzer info-level issues for pre-push hook Remove unnecessary dart:typed_data imports (redundant with flutter/foundation.dart), add const to ShearwaterRawDive constructors in tests where all fields are compile-time constants. --- .../data/parsers/shearwater_cloud_parser.dart | 2 - .../data/services/shearwater_db_reader.dart | 1 - .../services/shearwater_dive_mapper_test.dart | 78 +++++++++++-------- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart index 10058763a..1408bb8cb 100644 --- a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/foundation.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; diff --git a/lib/features/universal_import/data/services/shearwater_db_reader.dart b/lib/features/universal_import/data/services/shearwater_db_reader.dart index 04a7aa2a6..375810921 100644 --- a/lib/features/universal_import/data/services/shearwater_db_reader.dart +++ b/lib/features/universal_import/data/services/shearwater_db_reader.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:sqlite3/sqlite3.dart'; diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart index 6c0f99b51..327c55fa6 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart @@ -13,7 +13,7 @@ void main() { group('ShearwaterDiveMapper', () { group('mapDiveMetadata', () { test('maps core metadata fields', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test123', diveDate: '2025-12-27 14:01:08', depth: 26.80, @@ -70,7 +70,7 @@ void main() { }); test('maps conditions to structured enums', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', environment: 'Ocean/Sea', visibility: '30', @@ -88,7 +88,7 @@ void main() { }); test('converts imperial units', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', airTemperature: '72', weight: '14', @@ -104,7 +104,7 @@ void main() { }); test('does not convert metric units', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', airTemperature: '22', weight: '6', @@ -118,7 +118,7 @@ void main() { }); test('includes notes with extra notes appended', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', notes: 'Great dive', dress: 'Wet Suit', @@ -133,7 +133,7 @@ void main() { }); test('handles notes when only user notes present', () { - final rawDive = ShearwaterRawDive(diveId: 'test', notes: 'Great dive'); + const rawDive = ShearwaterRawDive(diveId: 'test', notes: 'Great dive'); final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); @@ -141,7 +141,7 @@ void main() { }); test('handles notes when only extra notes present', () { - final rawDive = ShearwaterRawDive(diveId: 'test', dress: 'Dry Suit'); + const rawDive = ShearwaterRawDive(diveId: 'test', dress: 'Dry Suit'); final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); final notes = result['notes'] as String; @@ -150,7 +150,7 @@ void main() { }); test('maps site reference for matching', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', site: 'Maclearie Park', ); @@ -164,7 +164,7 @@ void main() { }); test('extracts surface pressure from tank data', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', tankProfileData: { 'TankData': [ @@ -186,7 +186,7 @@ void main() { }); test('handles dive with no metadata gracefully', () { - final rawDive = ShearwaterRawDive(diveId: 'empty'); + const rawDive = ShearwaterRawDive(diveId: 'empty'); final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); @@ -198,7 +198,7 @@ void main() { }); test('maps dive computer info from filename', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', fileName: 'Perdix 2[AABB1234]#5 2025-01-15 09-30-00.swlogzp', ); @@ -210,7 +210,7 @@ void main() { }); test('defaults to oc diveMode when no apparatus set', () { - final rawDive = ShearwaterRawDive(diveId: 'test'); + const rawDive = ShearwaterRawDive(diveId: 'test'); final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); @@ -218,7 +218,7 @@ void main() { }); test('maps CCR apparatus to ccr diveMode', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', apparatus: 'Closed Circuit', ); @@ -229,7 +229,7 @@ void main() { }); test('maps SCR apparatus to scr diveMode', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', apparatus: 'Semi-Closed', ); @@ -242,7 +242,7 @@ void main() { group('mapTanks', () { test('maps active tanks with gas mix and pressures', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test123', footerJson: {'UnitSystem': 1}, tankProfileData: { @@ -280,7 +280,7 @@ void main() { }); test('maps multiple active tanks', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', footerJson: {'UnitSystem': 1}, tankProfileData: { @@ -317,7 +317,7 @@ void main() { }); test('maps trimix tank', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', footerJson: {'UnitSystem': 1}, tankProfileData: { @@ -341,7 +341,7 @@ void main() { }); test('returns empty list when no tank data', () { - final rawDive = ShearwaterRawDive(diveId: 'test'); + const rawDive = ShearwaterRawDive(diveId: 'test'); final tanks = ShearwaterDiveMapper.mapTanks(rawDive); @@ -349,7 +349,7 @@ void main() { }); test('returns empty list when TankData is missing', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', tankProfileData: {'GasProfiles': []}, ); @@ -360,7 +360,7 @@ void main() { }); test('handles tanks with missing pressure values', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', footerJson: {'UnitSystem': 1}, tankProfileData: { @@ -386,7 +386,7 @@ void main() { group('mapSites', () { test('maps site from location and site fields', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test123', location: 'Shark River, NJ, USA', site: 'Maclearie Park', @@ -401,9 +401,21 @@ void main() { test('deduplicates sites by name', () { final dives = [ - ShearwaterRawDive(diveId: '1', site: 'Same Site', location: 'NJ'), - ShearwaterRawDive(diveId: '2', site: 'Same Site', location: 'NJ'), - ShearwaterRawDive(diveId: '3', site: 'Other Site', location: 'FL'), + const ShearwaterRawDive( + diveId: '1', + site: 'Same Site', + location: 'NJ', + ), + const ShearwaterRawDive( + diveId: '2', + site: 'Same Site', + location: 'NJ', + ), + const ShearwaterRawDive( + diveId: '3', + site: 'Other Site', + location: 'FL', + ), ]; final sites = ShearwaterDiveMapper.mapSites(dives); @@ -415,9 +427,9 @@ void main() { test('skips dives with no site name', () { final dives = [ - ShearwaterRawDive(diveId: '1', site: 'Named Site'), - ShearwaterRawDive(diveId: '2'), - ShearwaterRawDive(diveId: '3', site: null), + const ShearwaterRawDive(diveId: '1', site: 'Named Site'), + const ShearwaterRawDive(diveId: '2'), + const ShearwaterRawDive(diveId: '3', site: null), ]; final sites = ShearwaterDiveMapper.mapSites(dives); @@ -428,8 +440,8 @@ void main() { test('returns empty list when no dives have sites', () { final dives = [ - ShearwaterRawDive(diveId: '1'), - ShearwaterRawDive(diveId: '2'), + const ShearwaterRawDive(diveId: '1'), + const ShearwaterRawDive(diveId: '2'), ]; final sites = ShearwaterDiveMapper.mapSites(dives); @@ -438,7 +450,7 @@ void main() { }); test('includes location as notes', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', site: 'My Site', location: 'Some Location', @@ -450,7 +462,7 @@ void main() { }); test('parses GNSS entry location to lat/lon', () { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', site: 'GPS Site', gnssEntryLocation: '40.1234,-74.5678', @@ -465,7 +477,7 @@ void main() { group('mapDive', () { test('falls back to metadata when no decompressed data', () async { - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'ffi-test', diveDate: '2025-06-15 10:30:00', depth: 20.0, @@ -872,7 +884,7 @@ void main() { test('string-to-double fallback via mapTanks', () { // _toDouble is private but exercised through mapTanks when // GasProfile values are strings instead of nums. - final rawDive = ShearwaterRawDive( + const rawDive = ShearwaterRawDive( diveId: 'test', tankProfileData: { 'TankData': [ From bd39645bb5fb6c2489d5cf068458ff8f04ff3610 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 02:17:25 -0400 Subject: [PATCH 09/12] test: add unit and widget tests for debug log viewer feature Cover LoggerService, debug log providers with filtering logic, DebugLogViewerPage, LogEntryTile, LogFilterBar, and ProviderContainer-based debug mode integration tests. Update DiveComputerService mock for logEvents stream. --- .../data/parsers/shearwater_cloud_parser.dart | 29 +- .../data/services/shearwater_db_reader.dart | 6 +- .../data/services/shearwater_dive_mapper.dart | 42 +- .../parsers/shearwater_cloud_parser_test.dart | 226 +++++++-- .../services/shearwater_db_reader_test.dart | 466 +++++++++++++++--- ...shearwater_dive_mapper_metadata_test.dart} | 454 ++--------------- .../shearwater_dive_mapper_profile_test.dart | 418 ++++++++++++++++ .../services/shearwater_test_helpers.dart | 257 ++++++++++ 8 files changed, 1334 insertions(+), 564 deletions(-) rename test/features/universal_import/data/services/{shearwater_dive_mapper_test.dart => shearwater_dive_mapper_metadata_test.dart} (55%) create mode 100644 test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart create mode 100644 test/features/universal_import/data/services/shearwater_test_helpers.dart diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart index 1408bb8cb..fd5000427 100644 --- a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -42,14 +42,6 @@ class ShearwaterCloudParser implements ImportParser { // 2. Read raw dives. final rawDives = await ShearwaterDbReader.readDives(fileBytes); - debugPrint('[ShearwaterCloudParser] Read ${rawDives.length} dives'); - for (final d in rawDives.take(3)) { - debugPrint( - '[ShearwaterCloudParser] Dive ${d.diveId}: ' - 'fileName=${d.fileName}, ' - 'logData=${d.decompressedLogData?.length ?? 0} bytes', - ); - } if (rawDives.isEmpty) { return const ImportPayload( entities: {}, @@ -63,15 +55,26 @@ class ShearwaterCloudParser implements ImportParser { } // 3. Map dives to ImportPayload entities. + // After the first FFI failure, fall back to metadata-only for remaining + // dives to avoid flooding the warning list (one warning is enough). final warnings = []; final diveEntities = >[]; + var ffiAvailable = true; for (final rawDive in rawDives) { - final diveMap = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, - ); - diveEntities.add(diveMap); + if (ffiAvailable) { + final beforeCount = warnings.length; + final diveMap = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + diveEntities.add(diveMap); + if (warnings.length > beforeCount) { + ffiAvailable = false; + } + } else { + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); + } } // 4. Extract unique sites. diff --git a/lib/features/universal_import/data/services/shearwater_db_reader.dart b/lib/features/universal_import/data/services/shearwater_db_reader.dart index 375810921..06e261fce 100644 --- a/lib/features/universal_import/data/services/shearwater_db_reader.dart +++ b/lib/features/universal_import/data/services/shearwater_db_reader.dart @@ -281,11 +281,7 @@ ORDER BY dd.DiveDate final deflateData = gzipBytes.sublist(deflateStart); final decoded = ZLibDecoder(raw: true).convert(deflateData); return Uint8List.fromList(decoded); - } catch (e) { - debugPrint( - '[ShearwaterDbReader] decompress failed: $e ' - '(raw=${raw.length} bytes)', - ); + } catch (_) { return null; } } diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart index 4864c18de..f7a2e32d7 100644 --- a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -33,8 +32,12 @@ class ShearwaterDiveMapper { 'importSource': 'shearwater_cloud', 'importId': rawDive.diveId, 'dateTime': _parseDateTime(rawDive.diveDate), - 'maxDepth': rawDive.depth, - 'avgDepth': rawDive.averageDepth, + 'maxDepth': isImperial && rawDive.depth != null + ? ShearwaterValueMapper.feetToMeters(rawDive.depth!) + : rawDive.depth, + 'avgDepth': isImperial && rawDive.averageDepth != null + ? ShearwaterValueMapper.feetToMeters(rawDive.averageDepth!) + : rawDive.averageDepth, 'runtime': rawDive.diveLengthTime != null ? Duration(seconds: rawDive.diveLengthTime!) : null, @@ -102,28 +105,13 @@ class ShearwaterDiveMapper { } try { - debugPrint( - '[ShearwaterDiveMapper] Attempting FFI parse: ' - '${vendorProduct.$1} ${vendorProduct.$2}, ' - '${logData.length} bytes', - ); final parsed = await _parseWithFfi( vendor: vendorProduct.$1, product: vendorProduct.$2, data: logData, ); - debugPrint( - '[ShearwaterDiveMapper] FFI success: ' - '${parsed.samples.length} samples, ' - 'depth=${parsed.maxDepthMeters}m', - ); return mergeWithParsedDive(baseMap, parsed); - } catch (e, stack) { - debugPrint( - '[ShearwaterDiveMapper] FFI parse failed for ' - 'dive ${rawDive.diveId}: $e', - ); - debugPrint('[ShearwaterDiveMapper] Stack: $stack'); + } catch (e) { warnings?.add( ImportWarning( severity: ImportWarningSeverity.warning, @@ -209,7 +197,21 @@ class ShearwaterDiveMapper { static DateTime? _parseDateTime(String? dateStr) { if (dateStr == null) return null; - return DateTime.tryParse(dateStr); + final parsed = DateTime.tryParse(dateStr); + if (parsed == null) return null; + if (parsed.isUtc) return parsed; + // Normalize naive/local datetimes to UTC wall-time to match the + // convention used by other import paths (ValueTransforms.parseDate). + return DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); } static int? _parseInt(String? value) { diff --git a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart index 05e29c8aa..932f70988 100644 --- a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart +++ b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart @@ -1,32 +1,93 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; -void main() { - late Uint8List dbBytes; - - setUpAll(() { - final file = File('third_party/shearwater_cloud_database.db'); - if (!file.existsSync()) { - fail('Test fixture not found: third_party/shearwater_cloud_database.db'); - } - dbBytes = file.readAsBytesSync(); - }); +import '../services/shearwater_test_helpers.dart'; +void main() { group('ShearwaterCloudParser', () { test('supportedFormats includes shearwaterDb', () { final parser = ShearwaterCloudParser(); expect(parser.supportedFormats, contains(ImportFormat.shearwaterDb)); }); - test('parse returns payload with 28 dives', () async { + test('parse returns error for non-Shearwater bytes', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(Uint8List.fromList([1, 2, 3])); + expect(payload.isEmpty, isTrue); + expect(payload.warnings, isNotEmpty); + expect(payload.warnings.first.severity, ImportWarningSeverity.error); + }); + + test('parse returns info warning for empty database', () async { + final parser = ShearwaterCloudParser(); + final bytes = createShearwaterTestDb(); + final payload = await parser.parse(bytes); + expect(payload.isEmpty, isTrue); + expect(payload.warnings, hasLength(1)); + expect(payload.warnings.first.severity, ImportWarningSeverity.info); + expect(payload.warnings.first.message, contains('no dives')); + }); + + test('parse returns dives from synthetic database', () async { + final tankJson = jsonEncode({ + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }); + final footerBlob = jsonToBlob({ + 'UnitSystem': 0, + 'DiveTimeInSeconds': 3600, + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'synth-001', + diveDate: '2025-06-15 10:30:00', + depth: 25.0, + averageDepth: 18.0, + diveLengthTime: 3600, + diveNumber: '1', + site: 'Test Reef', + location: 'Test Location', + environment: 'Ocean/Sea', + weather: 'Sunny', + fileName: 'Teric[AABB1234]#1 2025-06-15 10-30-00.swlogzp', + tankProfileDataJson: tankJson, + dataBytes3: footerBlob, + ), + ShearwaterTestDive( + diveId: 'synth-002', + diveDate: '2025-06-16 09:00:00', + depth: 18.0, + averageDepth: 12.0, + diveLengthTime: 2400, + diveNumber: '2', + site: 'Test Reef', + location: 'Test Location', + environment: 'Lake', + fileName: 'Teric[AABB1234]#2 2025-06-16 09-00-00.swlogzp', + dataBytes3: footerBlob, + ), + ], + ); + final parser = ShearwaterCloudParser(); final payload = await parser.parse( - dbBytes, + bytes, options: const ImportOptions( sourceApp: SourceApp.shearwater, format: ImportFormat.shearwaterDb, @@ -34,63 +95,122 @@ void main() { ); expect(payload.isNotEmpty, isTrue); + final dives = payload.entitiesOf(ImportEntityType.dives); - expect(dives, hasLength(28)); + expect(dives, hasLength(2)); + expect(dives[0]['importSource'], 'shearwater_cloud'); + expect(dives[0]['importId'], 'synth-001'); + expect(dives[0]['dateTime'], isA()); + expect(dives[0]['maxDepth'], 25.0); + expect(dives[0]['siteName'], 'Test Reef'); }); - test('parse includes sites', () async { - final parser = ShearwaterCloudParser(); - final payload = await parser.parse( - dbBytes, - options: const ImportOptions( - sourceApp: SourceApp.shearwater, - format: ImportFormat.shearwaterDb, - ), + test('parse extracts unique sites', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'site-1', + diveDate: '2025-06-15', + site: 'Reef A', + location: 'FL', + gnssEntryLocation: '25.0,-80.0', + ), + const ShearwaterTestDive( + diveId: 'site-2', + diveDate: '2025-06-16', + site: 'Reef A', + location: 'FL', + ), + const ShearwaterTestDive( + diveId: 'site-3', + diveDate: '2025-06-17', + site: 'Reef B', + ), + ], ); + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(bytes); + final sites = payload.entitiesOf(ImportEntityType.sites); - expect(sites, isNotEmpty); + expect(sites, hasLength(2)); + final names = sites.map((s) => s['name']).toSet(); + expect(names, containsAll(['Reef A', 'Reef B'])); }); - test('dive entities have required fields', () async { - final parser = ShearwaterCloudParser(); - final payload = await parser.parse( - dbBytes, - options: const ImportOptions( - sourceApp: SourceApp.shearwater, - format: ImportFormat.shearwaterDb, - ), - ); - - final dives = payload.entitiesOf(ImportEntityType.dives); - final dive = dives.firstWhere( - (d) => d['importId'] == '1676633251758354277', + test('metadata contains source and dive count', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive(diveId: 'd1', diveDate: '2025-01-01'), + const ShearwaterTestDive(diveId: 'd2', diveDate: '2025-01-02'), + ], ); - expect(dive['dateTime'], isA()); - expect(dive['maxDepth'], isA()); - expect(dive['importSource'], 'shearwater_cloud'); - expect(dive['notes'], contains('PADI Open Water')); - }); - test('parse returns empty payload for non-Shearwater bytes', () async { final parser = ShearwaterCloudParser(); - final payload = await parser.parse(Uint8List.fromList([1, 2, 3])); - expect(payload.isEmpty, isTrue); - expect(payload.warnings, isNotEmpty); + final payload = await parser.parse(bytes); + + expect(payload.metadata['source'], 'shearwater_cloud'); + expect(payload.metadata['diveCount'], 2); }); - test('metadata contains source and dive count', () async { + test('FFI failure produces single warning then falls back', () async { + final rawData = Uint8List.fromList(List.filled(100, 0)); + final compressed = createCompressedLogData(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'ffi-1', + diveDate: '2025-01-01', + fileName: 'Teric[AABB1234]#1 2025-01-01 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ShearwaterTestDive( + diveId: 'ffi-2', + diveDate: '2025-01-02', + fileName: 'Teric[AABB1234]#2 2025-01-02 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ShearwaterTestDive( + diveId: 'ffi-3', + diveDate: '2025-01-03', + fileName: 'Teric[AABB1234]#3 2025-01-03 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ], + ); + final parser = ShearwaterCloudParser(); - final payload = await parser.parse( - dbBytes, - options: const ImportOptions( - sourceApp: SourceApp.shearwater, - format: ImportFormat.shearwaterDb, - ), + final payload = await parser.parse(bytes); + + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(3)); + // Only one warning (for the first dive), not three + final ffiWarnings = payload.warnings.where( + (w) => w.message.contains('Profile parsing failed'), ); + expect(ffiWarnings, hasLength(1)); + }); - expect(payload.metadata['source'], 'shearwater_cloud'); - expect(payload.metadata['diveCount'], 28); + group('real fixture', () { + test('parses 28 dives from real database', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dbBytes = file.readAsBytesSync(); + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(28)); + }); }); }); } diff --git a/test/features/universal_import/data/services/shearwater_db_reader_test.dart b/test/features/universal_import/data/services/shearwater_db_reader_test.dart index ef014db27..41bd171f2 100644 --- a/test/features/universal_import/data/services/shearwater_db_reader_test.dart +++ b/test/features/universal_import/data/services/shearwater_db_reader_test.dart @@ -1,83 +1,427 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; -void main() { - late Uint8List dbBytes; - - setUpAll(() { - final file = File('third_party/shearwater_cloud_database.db'); - if (!file.existsSync()) { - fail('Test fixture not found: third_party/shearwater_cloud_database.db'); - } - dbBytes = file.readAsBytesSync(); - }); +import 'shearwater_test_helpers.dart'; +void main() { group('ShearwaterDbReader', () { - test('isShearwaterCloudDb returns true for valid database', () async { - final result = await ShearwaterDbReader.isShearwaterCloudDb(dbBytes); - expect(result, isTrue); + group('isShearwaterCloudDb', () { + test('returns true for valid Shearwater Cloud database', () async { + final bytes = createShearwaterTestDb(); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isTrue); + }); + + test('returns false for non-SQLite bytes', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb( + Uint8List.fromList([1, 2, 3, 4]), + ); + expect(result, isFalse); + }); + + test('returns false for empty bytes', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb( + Uint8List(0), + ); + expect(result, isFalse); + }); + + test('returns false for SQLite DB missing dive_details', () async { + final bytes = createShearwaterTestDb(includeDiveDetails: false); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isFalse); + }); + + test('returns false for SQLite DB missing log_data', () async { + final bytes = createShearwaterTestDb(includeLogData: false); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isFalse); + }); }); - test('isShearwaterCloudDb returns false for non-SQLite bytes', () async { - final result = await ShearwaterDbReader.isShearwaterCloudDb( - Uint8List.fromList([1, 2, 3, 4]), + group('readDives', () { + test('returns empty list for empty database', () async { + final bytes = createShearwaterTestDb(); + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives, isEmpty); + }); + + test('reads dive metadata from dive_details', () async { + final tankJson = jsonEncode({ + 'GasProfiles': [ + {'O2Percent': 32, 'HePercent': 0}, + ], + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'test-001', + diveDate: '2025-06-15 10:30:00', + depth: 26.8, + averageDepth: 19.4, + diveLengthTime: 1764, + diveNumber: '23', + serialNumber: '69FE56D7', + location: 'Shark River, NJ', + site: 'Maclearie Park', + buddy: 'John Doe', + notes: 'Great dive', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + airTemperature: '72', + weight: '14', + dress: 'Wet Suit', + apparatus: 'Open Circuit', + thermalComfort: 'Comfortable', + workload: 'Light', + gnssEntryLocation: '40.1234,-74.5678', + tankProfileDataJson: tankJson, + gasNotes: 'EAN32', + gearNotes: 'Perdix AI', + issueNotes: 'None', + endGF99: 0.85, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + + expect(dives, hasLength(1)); + final dive = dives.first; + expect(dive.diveId, 'test-001'); + expect(dive.diveDate, '2025-06-15 10:30:00'); + expect(dive.depth, 26.8); + expect(dive.averageDepth, 19.4); + expect(dive.diveLengthTime, 1764); + expect(dive.diveNumber, '23'); + expect(dive.serialNumber, '69FE56D7'); + expect(dive.location, 'Shark River, NJ'); + expect(dive.site, 'Maclearie Park'); + expect(dive.buddy, 'John Doe'); + expect(dive.notes, 'Great dive'); + expect(dive.environment, 'Ocean/Sea'); + expect(dive.visibility, '30'); + expect(dive.weather, 'Sunny'); + expect(dive.conditions, 'Current'); + expect(dive.airTemperature, '72'); + expect(dive.weight, '14'); + expect(dive.dress, 'Wet Suit'); + expect(dive.apparatus, 'Open Circuit'); + expect(dive.thermalComfort, 'Comfortable'); + expect(dive.workload, 'Light'); + expect(dive.gnssEntryLocation, '40.1234,-74.5678'); + expect(dive.gasNotes, 'EAN32'); + expect(dive.gearNotes, 'Perdix AI'); + expect(dive.issueNotes, 'None'); + expect(dive.endGF99, 0.85); + }); + + test('parses TankProfileData JSON string', () async { + final tankJson = jsonEncode({ + 'TankData': [ + { + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'IsOn': true, 'Name': 'T1'}, + }, + ], + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'tank-test', + tankProfileDataJson: tankJson, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.tankProfileData, isNotNull); + expect(dives.first.tankProfileData!['TankData'], isList); + }); + + test('parses calculatedValues JSON string', () async { + final calcJson = jsonEncode({'AverageDepth': 15.5, 'MaxDepth': 28.3}); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'calc-test', + calculatedValuesJson: calcJson, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.calculatedValues, isNotNull); + expect(dives.first.calculatedValues!['AverageDepth'], 15.5); + }); + + test('decompresses gzip data_bytes_1', () async { + final rawData = Uint8List.fromList(List.filled(256, 0xAB)); + final compressed = createCompressedLogData(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'gz-test', dataBytes1: compressed), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNotNull); + expect(dives.first.decompressedLogData!.length, rawData.length); + expect(dives.first.decompressedLogData, equals(rawData)); + }); + + test( + 'decompresses gzip with zeroed CRC via raw deflate fallback', + () async { + final rawData = Uint8List.fromList(List.filled(256, 0xCD)); + final compressed = createCompressedLogDataWithZeroedCrc(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'crc-test', dataBytes1: compressed), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNotNull); + expect(dives.first.decompressedLogData!.length, rawData.length); + expect(dives.first.decompressedLogData, equals(rawData)); + }, ); - expect(result, isFalse); - }); - test('readDives returns all dives from database', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - expect(dives, hasLength(28)); - }); + test('returns null for data_bytes_1 that is too short', () async { + // 4 prefix + 10 gzip header = 14 minimum; use 10 bytes + final tooShort = Uint8List.fromList(List.filled(10, 0)); - test('dive has metadata from dive_details', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.firstWhere((d) => d.diveId == '1676633251758354277'); - expect(dive.location, 'Shark River, NJ, USA'); - expect(dive.site, 'Maclearie Park'); - expect(dive.buddy, 'Kiyan Griffin'); - expect(dive.notes, 'PADI Open Water certification dive 1'); - expect(dive.environment, 'Ocean/Sea'); - }); + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'short-test', dataBytes1: tooShort), + ], + ); - test('dive has binary data decompressed from log_data', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.first; - expect(dive.decompressedLogData, isNotNull); - expect(dive.decompressedLogData, isNotEmpty); - }); + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNull); + }); - test('dive has filename from log_data', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.first; - expect(dive.fileName, contains('Teric')); - expect(dive.fileName, contains('.swlogzp')); - }); + test('returns null for completely invalid data_bytes_1', () async { + // Long enough but not valid gzip + final invalid = Uint8List.fromList(List.filled(100, 0xFF)); - test('dive has TankProfileData parsed as JSON', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.first; - expect(dive.tankProfileData, isNotNull); - expect(dive.tankProfileData!['GasProfiles'], isList); - expect(dive.tankProfileData!['TankData'], isList); - }); + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'invalid-test', dataBytes1: invalid), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNull); + }); + + test('parses header JSON from data_bytes_2', () async { + final headerJson = {'SomeHeader': 'value', 'Version': 42}; + final headerBlob = jsonToBlob(headerJson); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'hdr-test', dataBytes2: headerBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.headerJson, isNotNull); + expect(dives.first.headerJson!['SomeHeader'], 'value'); + expect(dives.first.headerJson!['Version'], 42); + }); + + test('parses footer JSON from data_bytes_3', () async { + final footerJson = {'UnitSystem': 1, 'DiveTimeInSeconds': 1764}; + final footerBlob = jsonToBlob(footerJson); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'ftr-test', dataBytes3: footerBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.footerJson, isNotNull); + expect(dives.first.footerJson!['UnitSystem'], 1); + expect(dives.first.footerJson!['DiveTimeInSeconds'], 1764); + }); + + test('normalizes empty strings to null', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'empty-str', + diveDate: '', + location: '', + site: '', + buddy: '', + notes: '', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + final dive = dives.first; + // Empty strings should be normalized to null by _str() + expect(dive.diveDate, isNull); + expect(dive.location, isNull); + expect(dive.site, isNull); + expect(dive.buddy, isNull); + expect(dive.notes, isNull); + }); + + test('handles null values gracefully', () async { + final bytes = createShearwaterTestDb( + dives: [const ShearwaterTestDive(diveId: 'nulls')], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + final dive = dives.first; + expect(dive.diveId, 'nulls'); + expect(dive.depth, isNull); + expect(dive.averageDepth, isNull); + expect(dive.diveLengthTime, isNull); + expect(dive.decompressedLogData, isNull); + expect(dive.headerJson, isNull); + expect(dive.footerJson, isNull); + expect(dive.tankProfileData, isNull); + expect(dive.calculatedValues, isNull); + }); + + test('reads multiple dives ordered by date', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'dive-2', + diveDate: '2025-06-16 09:00:00', + ), + const ShearwaterTestDive( + diveId: 'dive-1', + diveDate: '2025-06-15 10:00:00', + ), + const ShearwaterTestDive( + diveId: 'dive-3', + diveDate: '2025-06-17 08:00:00', + ), + ], + ); - test('dive has calculatedValues from log_data', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.first; - expect(dive.calculatedValues, isNotNull); - expect(dive.calculatedValues!['AverageDepth'], isA()); + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives, hasLength(3)); + expect(dives[0].diveId, 'dive-1'); + expect(dives[1].diveId, 'dive-2'); + expect(dives[2].diveId, 'dive-3'); + }); + + test('handles dive with log_data filename', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'fn-test', + fileName: 'Teric[69FE56D7]#23 2025-12-27 14-01-08.swlogzp', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.fileName, contains('Teric')); + expect(dives.first.fileName, contains('.swlogzp')); + }); + + test('handles invalid JSON in TankProfileData gracefully', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'bad-json', + tankProfileDataJson: 'not valid json {{{', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.tankProfileData, isNull); + }); + + test('handles invalid JSON in data_bytes_2 gracefully', () async { + final invalidBlob = Uint8List.fromList(utf8.encode('not json')); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'bad-hdr', dataBytes2: invalidBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.headerJson, isNull); + }); + + test('handles empty BLOB in data_bytes_3 gracefully', () async { + final emptyBlob = Uint8List(0); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'empty-blob', dataBytes3: emptyBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.footerJson, isNull); + }); }); - test('dive has footer JSON from data_bytes_3', () async { - final dives = await ShearwaterDbReader.readDives(dbBytes); - final dive = dives.first; - expect(dive.footerJson, isNotNull); - expect(dive.footerJson!['UnitSystem'], isA()); - expect(dive.footerJson!['DiveTimeInSeconds'], isA()); + group('real fixture', () { + late Uint8List dbBytes; + + setUpAll(() { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) return; + dbBytes = file.readAsBytesSync(); + }); + + test('reads 28 dives from real Shearwater Cloud database', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dives = await ShearwaterDbReader.readDives(dbBytes); + expect(dives, hasLength(28)); + }); + + test('real dives have decompressed log data', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dives = await ShearwaterDbReader.readDives(dbBytes); + final withData = dives + .where((d) => d.decompressedLogData != null) + .toList(); + expect(withData, isNotEmpty); + }); }); }); } diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart similarity index 55% rename from test/features/universal_import/data/services/shearwater_dive_mapper_test.dart rename to test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart index 327c55fa6..584b26a5f 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart @@ -1,11 +1,6 @@ -import 'dart:typed_data'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/universal_import/data/models/import_enums.dart'; -import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; @@ -58,8 +53,10 @@ void main() { expect((result['dateTime'] as DateTime).year, 2025); expect((result['dateTime'] as DateTime).month, 12); expect((result['dateTime'] as DateTime).day, 27); - expect(result['maxDepth'], 26.80); - expect(result['avgDepth'], 19.45); + expect((result['dateTime'] as DateTime).isUtc, isTrue); + // 26.80 ft = 8.169 m, 19.45 ft = 5.928 m (imperial -> metric) + expect(result['maxDepth'], closeTo(8.169, 0.001)); + expect(result['avgDepth'], closeTo(5.928, 0.001)); expect(result['runtime'], const Duration(seconds: 1764)); expect(result['diveNumber'], 23); expect(result['buddyRefs'], ['John Doe']); @@ -90,6 +87,8 @@ void main() { test('converts imperial units', () { const rawDive = ShearwaterRawDive( diveId: 'test', + depth: 90.0, + averageDepth: 60.0, airTemperature: '72', weight: '14', footerJson: {'UnitSystem': 1}, @@ -97,6 +96,10 @@ void main() { final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + // 90 ft = 27.432 m + expect(result['maxDepth'], closeTo(27.432, 0.001)); + // 60 ft = 18.288 m + expect(result['avgDepth'], closeTo(18.288, 0.001)); // 72F = 22.2C expect(result['airTemp'], closeTo(22.2, 0.1)); // 14lbs = 6.35kg @@ -106,6 +109,8 @@ void main() { test('does not convert metric units', () { const rawDive = ShearwaterRawDive( diveId: 'test', + depth: 27.0, + averageDepth: 18.0, airTemperature: '22', weight: '6', footerJson: {'UnitSystem': 0}, @@ -113,6 +118,8 @@ void main() { final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + expect(result['maxDepth'], 27.0); + expect(result['avgDepth'], 18.0); expect(result['airTemp'], closeTo(22.0, 0.1)); expect(result['weightAmount'], closeTo(6.0, 0.01)); }); @@ -238,6 +245,34 @@ void main() { expect(result['diveMode'], DiveMode.scr); }); + + test('normalizes naive datetime to UTC wall-time', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + diveDate: '2025-06-15 10:30:00', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final dt = result['dateTime'] as DateTime; + + expect(dt.isUtc, isTrue); + expect(dt.hour, 10); + expect(dt.minute, 30); + }); + + test('preserves UTC datetime as-is', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + diveDate: '2025-06-15T10:30:00Z', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final dt = result['dateTime'] as DateTime; + + expect(dt.isUtc, isTrue); + expect(dt.hour, 10); + expect(dt.minute, 30); + }); }); group('mapTanks', () { @@ -475,411 +510,6 @@ void main() { }); }); - group('mapDive', () { - test('falls back to metadata when no decompressed data', () async { - const rawDive = ShearwaterRawDive( - diveId: 'ffi-test', - diveDate: '2025-06-15 10:30:00', - depth: 20.0, - diveLengthTime: 3000, - ); - - final warnings = []; - final result = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, - ); - - expect(result['importSource'], 'shearwater_cloud'); - expect(result['importId'], 'ffi-test'); - expect(result['maxDepth'], 20.0); - // No warning expected because there was no decompressed data to parse - expect(warnings, isEmpty); - }); - - test('adds warning when FFI throws', () async { - // Provide decompressed data so it attempts FFI parsing, - // which will throw MissingPluginException in test environment - final rawDive = ShearwaterRawDive( - diveId: 'ffi-test', - diveDate: '2025-06-15 10:30:00', - depth: 20.0, - diveLengthTime: 3000, - fileName: 'Teric[AABB1234]#10 2025-06-15 10-30-00.swlogzp', - decompressedLogData: Uint8List.fromList(List.filled(100, 0)), - ); - - final warnings = []; - final result = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, - ); - - // Should still produce a valid map from metadata fallback - expect(result['importSource'], 'shearwater_cloud'); - expect(result['maxDepth'], 20.0); - // Should have a warning about FFI failure - expect(warnings, hasLength(1)); - expect(warnings[0].severity, ImportWarningSeverity.warning); - }); - - test('returns metadata only when model is unknown', () async { - final rawDive = ShearwaterRawDive( - diveId: 'test-unknown', - fileName: 'UnknownModel[ABCD]#1 2025-1-1 0-0-0.swlogzp', - decompressedLogData: Uint8List.fromList([1, 2, 3]), - ); - final warnings = []; - final result = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, - ); - expect(result['profile'], isEmpty); - expect(warnings, isNotEmpty); - expect(warnings.first.message, contains('Could not determine')); - expect(warnings.first.severity, ImportWarningSeverity.warning); - expect(warnings.first.entityType, ImportEntityType.dives); - }); - }); - - group('mergeWithParsedDive', () { - test('overrides depth/duration from parsed data', () { - final baseMap = { - 'maxDepth': 10.0, - 'avgDepth': 5.0, - 'runtime': const Duration(seconds: 100), - 'profile': >[], - }; - final parsed = pigeon.ParsedDive( - fingerprint: 'abc', - dateTimeYear: 2025, - dateTimeMonth: 12, - dateTimeDay: 27, - dateTimeHour: 14, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 26.8, - avgDepthMeters: 19.4, - durationSeconds: 1764, - samples: [], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['maxDepth'], 26.8); - expect(result['avgDepth'], 19.4); - expect((result['runtime'] as Duration).inSeconds, 1764); - }); - - test('adds deco algorithm and GF from parsed data', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 0, - avgDepthMeters: 0, - durationSeconds: 0, - samples: [], - tanks: [], - gasMixes: [], - events: [], - decoAlgorithm: 'buhlmann', - gfLow: 30, - gfHigh: 70, - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['decoAlgorithm'], 'buhlmann'); - expect(result['gradientFactorLow'], 30); - expect(result['gradientFactorHigh'], 70); - }); - - test('does not add deco fields when absent in parsed data', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 0, - avgDepthMeters: 0, - durationSeconds: 0, - samples: [], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result.containsKey('decoAlgorithm'), isFalse); - expect(result.containsKey('gradientFactorLow'), isFalse); - expect(result.containsKey('gradientFactorHigh'), isFalse); - }); - - test('maps dive mode from parsed data', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 0, - avgDepthMeters: 0, - durationSeconds: 0, - samples: [], - tanks: [], - gasMixes: [], - events: [], - diveMode: 'ccr', - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['diveMode'], DiveMode.ccr); - }); - - test('maps scr dive mode from parsed data', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 0, - avgDepthMeters: 0, - durationSeconds: 0, - samples: [], - tanks: [], - gasMixes: [], - events: [], - diveMode: 'scr', - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['diveMode'], DiveMode.scr); - }); - - test('maps unknown dive mode to oc', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 0, - avgDepthMeters: 0, - durationSeconds: 0, - samples: [], - tanks: [], - gasMixes: [], - events: [], - diveMode: 'gauge', - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['diveMode'], DiveMode.oc); - }); - - test('builds profile samples with all sensor data', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 20, - avgDepthMeters: 10, - durationSeconds: 600, - samples: [ - pigeon.ProfileSample( - timeSeconds: 10, - depthMeters: 5.0, - temperatureCelsius: 22.0, - pressureBar: 200.0, - setpoint: 1.3, - ppo2: 1.1, - heartRate: 80, - cns: 5.0, - rbt: 60, - tts: 120, - decoType: 0, - decoTime: 99, - decoDepth: 3.0, - ), - pigeon.ProfileSample(timeSeconds: 20, depthMeters: 10.0), - ], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - final profile = result['profile'] as List; - expect(profile, hasLength(2)); - - final s1 = profile[0] as Map; - expect(s1['timestamp'], 10); - expect(s1['depth'], 5.0); - expect(s1['temperature'], 22.0); - expect(s1['pressure'], 200.0); - expect(s1['setpoint'], 1.3); - expect(s1['ppO2'], 1.1); - expect(s1['heartRate'], 80); - expect(s1['cns'], 5.0); - expect(s1['rbt'], 60); - expect(s1['tts'], 120); - expect(s1['decoType'], 0); - expect(s1['ceiling'], 3.0); - expect(s1['ndl'], 99); - - // Second sample has only depth -- no optional fields - final s2 = profile[1] as Map; - expect(s2['timestamp'], 20); - expect(s2['depth'], 10.0); - expect(s2.containsKey('temperature'), isFalse); - expect(s2.containsKey('pressure'), isFalse); - expect(s2.containsKey('setpoint'), isFalse); - expect(s2.containsKey('ppO2'), isFalse); - expect(s2.containsKey('heartRate'), isFalse); - expect(s2.containsKey('cns'), isFalse); - expect(s2.containsKey('rbt'), isFalse); - expect(s2.containsKey('tts'), isFalse); - expect(s2.containsKey('decoType'), isFalse); - expect(s2.containsKey('ceiling'), isFalse); - expect(s2.containsKey('ndl'), isFalse); - }); - - test('extracts water temp from samples when not in metadata', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 10, - avgDepthMeters: 5, - durationSeconds: 300, - samples: [ - pigeon.ProfileSample( - timeSeconds: 10, - depthMeters: 5.0, - temperatureCelsius: 22.0, - ), - pigeon.ProfileSample( - timeSeconds: 20, - depthMeters: 8.0, - temperatureCelsius: 20.0, - ), - ], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['waterTemp'], 20.0); // min temperature - }); - - test('does not override existing waterTemp', () { - final baseMap = { - 'waterTemp': 25.0, - 'profile': >[], - }; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 10, - avgDepthMeters: 5, - durationSeconds: 300, - samples: [ - pigeon.ProfileSample( - timeSeconds: 10, - depthMeters: 5.0, - temperatureCelsius: 20.0, - ), - ], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['waterTemp'], 25.0); // unchanged - }); - - test('does not set waterTemp when no temperature samples exist', () { - final baseMap = {'profile': >[]}; - final parsed = pigeon.ParsedDive( - fingerprint: '', - dateTimeYear: 2025, - dateTimeMonth: 1, - dateTimeDay: 1, - dateTimeHour: 0, - dateTimeMinute: 0, - dateTimeSecond: 0, - maxDepthMeters: 10, - avgDepthMeters: 5, - durationSeconds: 300, - samples: [pigeon.ProfileSample(timeSeconds: 10, depthMeters: 5.0)], - tanks: [], - gasMixes: [], - events: [], - ); - final result = ShearwaterDiveMapper.mergeWithParsedDive( - baseMap, - parsed, - ); - expect(result['waterTemp'], isNull); - }); - }); - group('_toDouble fallback', () { test('string-to-double fallback via mapTanks', () { // _toDouble is private but exercised through mapTanks when diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart new file mode 100644 index 000000000..40b96b00d --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart @@ -0,0 +1,418 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +void main() { + group('ShearwaterDiveMapper', () { + group('mapDive', () { + test('falls back to metadata when no decompressed data', () async { + const rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + ); + + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'ffi-test'); + expect(result['maxDepth'], 20.0); + // No warning expected because there was no decompressed data to parse + expect(warnings, isEmpty); + }); + + test('adds warning when FFI throws', () async { + // Provide decompressed data so it attempts FFI parsing, + // which will throw MissingPluginException in test environment + final rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + fileName: 'Teric[AABB1234]#10 2025-06-15 10-30-00.swlogzp', + decompressedLogData: Uint8List.fromList(List.filled(100, 0)), + ); + + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + + // Should still produce a valid map from metadata fallback + expect(result['importSource'], 'shearwater_cloud'); + expect(result['maxDepth'], 20.0); + // Should have a warning about FFI failure + expect(warnings, hasLength(1)); + expect(warnings[0].severity, ImportWarningSeverity.warning); + }); + + test('returns metadata only when model is unknown', () async { + final rawDive = ShearwaterRawDive( + diveId: 'test-unknown', + fileName: 'UnknownModel[ABCD]#1 2025-1-1 0-0-0.swlogzp', + decompressedLogData: Uint8List.fromList([1, 2, 3]), + ); + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + expect(result['profile'], isEmpty); + expect(warnings, isNotEmpty); + expect(warnings.first.message, contains('Could not determine')); + expect(warnings.first.severity, ImportWarningSeverity.warning); + expect(warnings.first.entityType, ImportEntityType.dives); + }); + }); + + group('mergeWithParsedDive', () { + test('overrides depth/duration from parsed data', () { + final baseMap = { + 'maxDepth': 10.0, + 'avgDepth': 5.0, + 'runtime': const Duration(seconds: 100), + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: 'abc', + dateTimeYear: 2025, + dateTimeMonth: 12, + dateTimeDay: 27, + dateTimeHour: 14, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 26.8, + avgDepthMeters: 19.4, + durationSeconds: 1764, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['maxDepth'], 26.8); + expect(result['avgDepth'], 19.4); + expect((result['runtime'] as Duration).inSeconds, 1764); + }); + + test('adds deco algorithm and GF from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + decoAlgorithm: 'buhlmann', + gfLow: 30, + gfHigh: 70, + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['decoAlgorithm'], 'buhlmann'); + expect(result['gradientFactorLow'], 30); + expect(result['gradientFactorHigh'], 70); + }); + + test('does not add deco fields when absent in parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result.containsKey('decoAlgorithm'), isFalse); + expect(result.containsKey('gradientFactorLow'), isFalse); + expect(result.containsKey('gradientFactorHigh'), isFalse); + }); + + test('maps dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'ccr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.ccr); + }); + + test('maps scr dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'scr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.scr); + }); + + test('maps unknown dive mode to oc', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'gauge', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.oc); + }); + + test('builds profile samples with all sensor data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 20, + avgDepthMeters: 10, + durationSeconds: 600, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + pressureBar: 200.0, + setpoint: 1.3, + ppo2: 1.1, + heartRate: 80, + cns: 5.0, + rbt: 60, + tts: 120, + decoType: 0, + decoTime: 99, + decoDepth: 3.0, + ), + pigeon.ProfileSample(timeSeconds: 20, depthMeters: 10.0), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + final profile = result['profile'] as List; + expect(profile, hasLength(2)); + + final s1 = profile[0] as Map; + expect(s1['timestamp'], 10); + expect(s1['depth'], 5.0); + expect(s1['temperature'], 22.0); + expect(s1['pressure'], 200.0); + expect(s1['setpoint'], 1.3); + expect(s1['ppO2'], 1.1); + expect(s1['heartRate'], 80); + expect(s1['cns'], 5.0); + expect(s1['rbt'], 60); + expect(s1['tts'], 120); + expect(s1['decoType'], 0); + expect(s1['ceiling'], 3.0); + expect(s1['ndl'], 99); + + // Second sample has only depth -- no optional fields + final s2 = profile[1] as Map; + expect(s2['timestamp'], 20); + expect(s2['depth'], 10.0); + expect(s2.containsKey('temperature'), isFalse); + expect(s2.containsKey('pressure'), isFalse); + expect(s2.containsKey('setpoint'), isFalse); + expect(s2.containsKey('ppO2'), isFalse); + expect(s2.containsKey('heartRate'), isFalse); + expect(s2.containsKey('cns'), isFalse); + expect(s2.containsKey('rbt'), isFalse); + expect(s2.containsKey('tts'), isFalse); + expect(s2.containsKey('decoType'), isFalse); + expect(s2.containsKey('ceiling'), isFalse); + expect(s2.containsKey('ndl'), isFalse); + }); + + test('extracts water temp from samples when not in metadata', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + ), + pigeon.ProfileSample( + timeSeconds: 20, + depthMeters: 8.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 20.0); // min temperature + }); + + test('does not override existing waterTemp', () { + final baseMap = { + 'waterTemp': 25.0, + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 25.0); // unchanged + }); + + test('does not set waterTemp when no temperature samples exist', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [pigeon.ProfileSample(timeSeconds: 10, depthMeters: 5.0)], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], isNull); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_test_helpers.dart b/test/features/universal_import/data/services/shearwater_test_helpers.dart new file mode 100644 index 000000000..2dd2141f6 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_test_helpers.dart @@ -0,0 +1,257 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:sqlite3/sqlite3.dart'; + +/// Creates a minimal Shearwater Cloud SQLite database as bytes. +/// +/// This allows tests to run without the real fixture file (which is not +/// committed to git). The resulting bytes can be passed directly to +/// [ShearwaterDbReader.isShearwaterCloudDb] and [readDives]. +Uint8List createShearwaterTestDb({ + List dives = const [], + bool includeDiveDetails = true, + bool includeLogData = true, +}) { + final tempPath = + '${Directory.systemTemp.path}/sw_test_${DateTime.now().millisecondsSinceEpoch}.db'; + final db = sqlite3.open(tempPath); + try { + if (includeDiveDetails) { + db.execute(''' + CREATE TABLE dive_details ( + DiveId TEXT PRIMARY KEY, + DiveDate TEXT, + Depth REAL, + AverageDepth REAL, + DiveLengthTime INTEGER, + DiveNumber TEXT, + SerialNumber TEXT, + Location TEXT, + Site TEXT, + Buddy TEXT, + Notes TEXT, + Environment TEXT, + Visibility TEXT, + Weather TEXT, + Conditions TEXT, + AirTemperature TEXT, + Weight TEXT, + Dress TEXT, + Apparatus TEXT, + ThermalComfort TEXT, + Workload TEXT, + Problems TEXT, + Malfunctions TEXT, + Symptoms TEXT, + GnssEntryLocation TEXT, + GnssExitLocation TEXT, + TankProfileData TEXT, + GasNotes TEXT, + GearNotes TEXT, + IssueNotes TEXT, + EndGF99 REAL + ) + '''); + } + + if (includeLogData) { + db.execute(''' + CREATE TABLE log_data ( + log_id TEXT PRIMARY KEY, + file_name TEXT, + data_bytes_1 BLOB, + data_bytes_2 BLOB, + data_bytes_3 BLOB, + calculated_values_from_samples TEXT + ) + '''); + } + + for (final dive in dives) { + if (includeDiveDetails) { + db.execute( + '''INSERT INTO dive_details ( + DiveId, DiveDate, Depth, AverageDepth, DiveLengthTime, + DiveNumber, SerialNumber, Location, Site, Buddy, Notes, + Environment, Visibility, Weather, Conditions, + AirTemperature, Weight, Dress, Apparatus, + ThermalComfort, Workload, Problems, Malfunctions, Symptoms, + GnssEntryLocation, GnssExitLocation, TankProfileData, + GasNotes, GearNotes, IssueNotes, EndGF99 + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ? + )''', + [ + dive.diveId, + dive.diveDate, + dive.depth, + dive.averageDepth, + dive.diveLengthTime, + dive.diveNumber, + dive.serialNumber, + dive.location, + dive.site, + dive.buddy, + dive.notes, + dive.environment, + dive.visibility, + dive.weather, + dive.conditions, + dive.airTemperature, + dive.weight, + dive.dress, + dive.apparatus, + dive.thermalComfort, + dive.workload, + dive.problems, + dive.malfunctions, + dive.symptoms, + dive.gnssEntryLocation, + dive.gnssExitLocation, + dive.tankProfileDataJson, + dive.gasNotes, + dive.gearNotes, + dive.issueNotes, + dive.endGF99, + ], + ); + } + + if (includeLogData) { + db.execute( + '''INSERT INTO log_data ( + log_id, file_name, data_bytes_1, data_bytes_2, + data_bytes_3, calculated_values_from_samples + ) VALUES (?, ?, ?, ?, ?, ?)''', + [ + dive.diveId, + dive.fileName, + dive.dataBytes1, + dive.dataBytes2, + dive.dataBytes3, + dive.calculatedValuesJson, + ], + ); + } + } + } finally { + db.dispose(); + } + + final bytes = File(tempPath).readAsBytesSync(); + File(tempPath).deleteSync(); + return bytes; +} + +/// Compresses [rawData] as gzip with the 4-byte LE length prefix that +/// Shearwater Cloud uses for data_bytes_1. +Uint8List createCompressedLogData(Uint8List rawData) { + final compressed = GZipCodec().encode(rawData); + final length = ByteData(4)..setUint32(0, rawData.length, Endian.little); + return Uint8List.fromList([...length.buffer.asUint8List(), ...compressed]); +} + +/// Creates gzip data with zeroed CRC32/ISIZE trailer (triggers raw deflate +/// fallback in the reader). +Uint8List createCompressedLogDataWithZeroedCrc(Uint8List rawData) { + final normal = createCompressedLogData(rawData); + // Zero out the last 8 bytes (CRC32 + ISIZE) of the gzip stream + // The 4-byte prefix is not part of gzip, so gzip starts at index 4 + final result = Uint8List.fromList(normal); + for (var i = result.length - 8; i < result.length; i++) { + result[i] = 0; + } + return result; +} + +/// Encodes a JSON map to a UTF-8 BLOB (for data_bytes_2 / data_bytes_3). +Uint8List jsonToBlob(Map json) { + return Uint8List.fromList(utf8.encode(jsonEncode(json))); +} + +/// Test data for a single Shearwater dive row. +class ShearwaterTestDive { + final String diveId; + final String? diveDate; + final double? depth; + final double? averageDepth; + final int? diveLengthTime; + final String? diveNumber; + final String? serialNumber; + final String? location; + final String? site; + final String? buddy; + final String? notes; + final String? environment; + final String? visibility; + final String? weather; + final String? conditions; + final String? airTemperature; + final String? weight; + final String? dress; + final String? apparatus; + final String? thermalComfort; + final String? workload; + final String? problems; + final String? malfunctions; + final String? symptoms; + final String? gnssEntryLocation; + final String? gnssExitLocation; + final String? tankProfileDataJson; + final String? gasNotes; + final String? gearNotes; + final String? issueNotes; + final double? endGF99; + final String? fileName; + final Uint8List? dataBytes1; + final Uint8List? dataBytes2; + final Uint8List? dataBytes3; + final String? calculatedValuesJson; + + const ShearwaterTestDive({ + required this.diveId, + this.diveDate, + this.depth, + this.averageDepth, + this.diveLengthTime, + this.diveNumber, + this.serialNumber, + this.location, + this.site, + this.buddy, + this.notes, + this.environment, + this.visibility, + this.weather, + this.conditions, + this.airTemperature, + this.weight, + this.dress, + this.apparatus, + this.thermalComfort, + this.workload, + this.problems, + this.malfunctions, + this.symptoms, + this.gnssEntryLocation, + this.gnssExitLocation, + this.tankProfileDataJson, + this.gasNotes, + this.gearNotes, + this.issueNotes, + this.endGF99, + this.fileName, + this.dataBytes1, + this.dataBytes2, + this.dataBytes3, + this.calculatedValuesJson, + }); +} From e8ff9f58cc92be2bb12b49b9c4f0a9cdac2d38d4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 03:11:50 -0400 Subject: [PATCH 10/12] fix: improve Shearwater import error handling for FFI unavailability Catch MissingPluginException and PlatformException separately in the parser and mapper so platform-level FFI failures cleanly fall back to metadata-only import without per-dive warning noise. Wrap DB read in try/catch to surface database errors as import warnings. --- .../data/parsers/shearwater_cloud_parser.dart | 48 ++++++++++++++----- .../data/services/shearwater_dive_mapper.dart | 20 +++++++- .../parsers/shearwater_cloud_parser_test.dart | 8 +++- .../shearwater_dive_mapper_profile_test.dart | 24 ++++------ 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart index fd5000427..cab50f5a6 100644 --- a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; @@ -41,7 +41,20 @@ class ShearwaterCloudParser implements ImportParser { } // 2. Read raw dives. - final rawDives = await ShearwaterDbReader.readDives(fileBytes); + final List rawDives; + try { + rawDives = await ShearwaterDbReader.readDives(fileBytes); + } catch (e) { + return ImportPayload( + entities: const {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: 'Failed to read dives from database: $e', + ), + ], + ); + } if (rawDives.isEmpty) { return const ImportPayload( entities: {}, @@ -55,22 +68,35 @@ class ShearwaterCloudParser implements ImportParser { } // 3. Map dives to ImportPayload entities. - // After the first FFI failure, fall back to metadata-only for remaining - // dives to avoid flooding the warning list (one warning is enough). + // After the first platform-level FFI failure, fall back to + // metadata-only for remaining dives. final warnings = []; final diveEntities = >[]; var ffiAvailable = true; for (final rawDive in rawDives) { if (ffiAvailable) { - final beforeCount = warnings.length; - final diveMap = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, - ); - diveEntities.add(diveMap); - if (warnings.length > beforeCount) { + try { + final diveMap = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + diveEntities.add(diveMap); + } on MissingPluginException { + ffiAvailable = false; + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); + } on PlatformException { ffiAvailable = false; + warnings.add( + const ImportWarning( + severity: ImportWarningSeverity.info, + message: + 'Profile parsing is not available on this platform. ' + 'Dives will be imported with metadata only.', + entityType: ImportEntityType.dives, + ), + ); + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); } } else { diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart index f7a2e32d7..0b8bf8d65 100644 --- a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -71,8 +72,11 @@ class ShearwaterDiveMapper { /// /// If [rawDive.decompressedLogData] is available, tries to call /// [DiveComputerHostApi().parseRawDiveData()] to get rich profile data. - /// On failure (including [MissingPluginException] in test environments), - /// adds a warning and returns the metadata-only map. + /// + /// Platform-level errors ([MissingPluginException], [PlatformException] + /// with code `UNSUPPORTED`) are rethrown so the caller can detect that + /// FFI is unavailable and skip it for remaining dives. Data-level errors + /// (corrupt dive data) are caught and added to [warnings]. static Future> mapDive( ShearwaterRawDive rawDive, { List? warnings, @@ -111,6 +115,18 @@ class ShearwaterDiveMapper { data: logData, ); return mergeWithParsedDive(baseMap, parsed); + } on MissingPluginException { + rethrow; + } on PlatformException catch (e) { + if (e.code == 'UNSUPPORTED' || e.code == 'channel-error') rethrow; + warnings?.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Profile parsing failed for dive ${rawDive.diveId}: $e', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; } catch (e) { warnings?.add( ImportWarning( diff --git a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart index 932f70988..f533d0f0a 100644 --- a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart +++ b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart @@ -11,6 +11,8 @@ import 'package:submersion/features/universal_import/data/parsers/shearwater_clo import '../services/shearwater_test_helpers.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('ShearwaterCloudParser', () { test('supportedFormats includes shearwaterDb', () { final parser = ShearwaterCloudParser(); @@ -185,11 +187,13 @@ void main() { final dives = payload.entitiesOf(ImportEntityType.dives); expect(dives, hasLength(3)); - // Only one warning (for the first dive), not three + // MissingPluginException is caught by the parser on the first dive, + // FFI is disabled, and remaining dives use metadata-only. + // No per-dive "Profile parsing failed" warnings should appear. final ffiWarnings = payload.warnings.where( (w) => w.message.contains('Profile parsing failed'), ); - expect(ffiWarnings, hasLength(1)); + expect(ffiWarnings, isEmpty); }); group('real fixture', () { diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart index 40b96b00d..eca96ab79 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; @@ -32,9 +33,12 @@ void main() { expect(warnings, isEmpty); }); - test('adds warning when FFI throws', () async { - // Provide decompressed data so it attempts FFI parsing, - // which will throw MissingPluginException in test environment + test('rethrows platform exception from FFI', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + // Provide decompressed data so it attempts FFI parsing. + // In the test environment, the Pigeon channel throws a + // PlatformException (channel-error). Platform-level errors are + // rethrown so the parser can detect FFI is unavailable. final rawDive = ShearwaterRawDive( diveId: 'ffi-test', diveDate: '2025-06-15 10:30:00', @@ -44,18 +48,10 @@ void main() { decompressedLogData: Uint8List.fromList(List.filled(100, 0)), ); - final warnings = []; - final result = await ShearwaterDiveMapper.mapDive( - rawDive, - warnings: warnings, + expect( + () => ShearwaterDiveMapper.mapDive(rawDive), + throwsA(isA()), ); - - // Should still produce a valid map from metadata fallback - expect(result['importSource'], 'shearwater_cloud'); - expect(result['maxDepth'], 20.0); - // Should have a warning about FFI failure - expect(warnings, hasLength(1)); - expect(warnings[0].severity, ImportWarningSeverity.warning); }); test('returns metadata only when model is unknown', () async { From 169867339c06cd7a1d522374c3b1cc900d07e8f2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 03:14:10 -0400 Subject: [PATCH 11/12] style: remove unnecessary dart:typed_data import in profile test --- .../data/services/shearwater_dive_mapper_profile_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart index eca96ab79..349cbd670 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; From 6b2ab0c2ebbe32c8ea689fd6bb726d777102cf9d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 28 Mar 2026 14:26:30 -0400 Subject: [PATCH 12/12] fix: gate ceiling and ndl fields by decoType in Shearwater profile mapping Only emit ceiling when the sample represents a deco stop (decoType != 0) and ndl when in no-deco mode (decoType == 0). Previously both fields could appear in samples where they had no semantic meaning. --- .../data/parsers/shearwater_cloud_parser.dart | 1 + .../data/services/shearwater_dive_mapper.dart | 6 ++- .../shearwater_dive_mapper_profile_test.dart | 39 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart index cab50f5a6..5527710a6 100644 --- a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; + import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart index 0b8bf8d65..07a3dbc30 100644 --- a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; + import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; + import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; @@ -400,10 +402,10 @@ class ShearwaterDiveMapper { if (s.decoType != null) { sampleMap['decoType'] = s.decoType; } - if (s.decoDepth != null) { + if (s.decoDepth != null && s.decoType != null && s.decoType != 0) { sampleMap['ceiling'] = s.decoDepth; } - if (s.decoTime != null) { + if (s.decoType == 0 && s.decoTime != null) { sampleMap['ndl'] = s.decoTime; } return sampleMap; diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart index 349cbd670..1caf6600c 100644 --- a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart @@ -293,7 +293,7 @@ void main() { expect(s1['rbt'], 60); expect(s1['tts'], 120); expect(s1['decoType'], 0); - expect(s1['ceiling'], 3.0); + expect(s1.containsKey('ceiling'), isFalse); expect(s1['ndl'], 99); // Second sample has only depth -- no optional fields @@ -313,6 +313,43 @@ void main() { expect(s2.containsKey('ndl'), isFalse); }); + test('maps deco stop samples with ceiling and no ndl', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 30, + avgDepthMeters: 20, + durationSeconds: 1800, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 25.0, + decoType: 2, + decoTime: 180, + decoDepth: 6.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + final profile = result['profile'] as List; + final s1 = profile[0] as Map; + expect(s1['decoType'], 2); + expect(s1['ceiling'], 6.0); + expect(s1.containsKey('ndl'), isFalse); + }); + test('extracts water temp from samples when not in metadata', () { final baseMap = {'profile': >[]}; final parsed = pigeon.ParsedDive(