diff --git a/lib/features/dive_computer/presentation/providers/download_providers.dart b/lib/features/dive_computer/presentation/providers/download_providers.dart index 76de5fdfe..87b3a2c5a 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/models/log_entry.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; @@ -111,6 +113,8 @@ class DownloadState { /// record when the download completes. Import and consolidation are handled /// by the unified import wizard via [DiveComputerAdapter]. class DownloadNotifier extends StateNotifier { + static final LoggerService _log = LoggerService.forClass(DownloadNotifier); + final pigeon.DiveComputerService _service; final DiveComputerRepository _repository; StreamSubscription? _downloadSubscription; @@ -159,7 +163,17 @@ class DownloadNotifier extends StateNotifier { } await _service.startDownload(device.toPigeon(), fingerprint: fingerprint); - } catch (e) { + } catch (e, stackTrace) { + _log.error( + 'Download failed', + category: LogCategory.libdc, + error: e, + stackTrace: stackTrace, + ); + // Cancel the event subscription so stray events from the native side + // cannot mutate state after a synchronous start failure. + _downloadSubscription?.cancel(); + _downloadSubscription = null; state = state.copyWith( phase: DownloadPhase.error, errorMessage: 'Download failed: $e', @@ -200,6 +214,10 @@ class DownloadNotifier extends StateNotifier { // Persist device info on the computer record. _persistDeviceInfo(serialNumber, firmwareVersion); case pigeon.DownloadErrorEvent(:final error): + _log.error( + 'Download failed (${error.code}): ${error.message}', + category: LogCategory.libdc, + ); state = state.copyWith( phase: DownloadPhase.error, errorMessage: error.message, diff --git a/test/features/dive_computer/presentation/providers/download_notifier_fingerprint_test.dart b/test/features/dive_computer/presentation/providers/download_notifier_fingerprint_test.dart index 000a918b0..b831df189 100644 --- a/test/features/dive_computer/presentation/providers/download_notifier_fingerprint_test.dart +++ b/test/features/dive_computer/presentation/providers/download_notifier_fingerprint_test.dart @@ -5,6 +5,8 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' hide DiscoveredDevice; +import 'package:submersion/core/models/log_entry.dart'; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/dive_computer/domain/entities/device_model.dart'; import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; import 'package:submersion/features/dive_computer/presentation/providers/download_providers.dart'; @@ -87,4 +89,131 @@ void main() { await controller.close(); }); }); + + group('download failures are logged', () { + List captureLibdcErrors() { + final entries = []; + final sub = LoggerService.logStream + .where( + (e) => e.level == LogLevel.error && e.category == LogCategory.libdc, + ) + .listen(entries.add); + addTearDown(sub.cancel); + return entries; + } + + test('DownloadErrorEvent writes an ERROR log entry', () async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + when(mockService.downloadEvents).thenAnswer((_) => controller.stream); + when( + mockService.startDownload(any, fingerprint: anyNamed('fingerprint')), + ).thenAnswer((_) async {}); + + final testNotifier = DownloadNotifier( + service: mockService, + repository: mockRepository, + ); + addTearDown(testNotifier.dispose); + + final errorEntries = captureLibdcErrors(); + + final device = DiscoveredDevice( + id: 'test-err-1', + name: 'Test Device', + connectionType: DeviceConnectionType.ble, + address: '00:11:22:33:44:55', + discoveredAt: DateTime(2026, 1, 1), + ); + + await testNotifier.startDownload(device); + + controller.add( + DownloadErrorEvent( + DiveComputerError( + code: 'comm_timeout', + message: 'Communication timeout', + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(errorEntries, hasLength(1)); + expect(errorEntries.first.message, contains('comm_timeout')); + expect(errorEntries.first.message, contains('Communication timeout')); + }); + + test( + 'Exception thrown by startDownload writes an ERROR log entry', + () async { + when( + mockService.downloadEvents, + ).thenAnswer((_) => const Stream.empty()); + when( + mockService.startDownload(any, fingerprint: anyNamed('fingerprint')), + ).thenThrow(StateError('boom')); + + final testNotifier = DownloadNotifier( + service: mockService, + repository: mockRepository, + ); + addTearDown(testNotifier.dispose); + + final errorEntries = captureLibdcErrors(); + + final device = DiscoveredDevice( + id: 'test-err-2', + name: 'Test Device', + connectionType: DeviceConnectionType.usb, + address: 'COM3', + discoveredAt: DateTime(2026, 1, 1), + ); + + await testNotifier.startDownload(device); + await Future.delayed(Duration.zero); + + expect(errorEntries, hasLength(1)); + expect(errorEntries.first.message, contains('boom')); + }, + ); + + test('startDownload catch block cancels the events subscription', () async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + when(mockService.downloadEvents).thenAnswer((_) => controller.stream); + when( + mockService.startDownload(any, fingerprint: anyNamed('fingerprint')), + ).thenThrow(StateError('boom')); + + final testNotifier = DownloadNotifier( + service: mockService, + repository: mockRepository, + ); + addTearDown(testNotifier.dispose); + + final errorEntries = captureLibdcErrors(); + + final device = DiscoveredDevice( + id: 'test-err-3', + name: 'Test Device', + connectionType: DeviceConnectionType.usb, + address: 'COM3', + discoveredAt: DateTime(2026, 1, 1), + ); + + await testNotifier.startDownload(device); + + // If the catch block did not cancel the subscription, this stray + // event would reach _onDownloadEvent and emit a second error log. + controller.add( + DownloadErrorEvent( + DiveComputerError(code: 'stray', message: 'Stray event'), + ), + ); + await Future.delayed(Duration.zero); + + expect(errorEntries, hasLength(1)); + expect(errorEntries.first.message, contains('boom')); + }); + }); }