Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<DownloadState> {
static final LoggerService _log = LoggerService.forClass(DownloadNotifier);

final pigeon.DiveComputerService _service;
final DiveComputerRepository _repository;
StreamSubscription<pigeon.DownloadEvent>? _downloadSubscription;
Expand Down Expand Up @@ -159,7 +163,17 @@ class DownloadNotifier extends StateNotifier<DownloadState> {
}

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',
Expand Down Expand Up @@ -200,6 +214,10 @@ class DownloadNotifier extends StateNotifier<DownloadState> {
// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,4 +89,131 @@ void main() {
await controller.close();
});
});

group('download failures are logged', () {
List<LogEntry> captureLibdcErrors() {
final entries = <LogEntry>[];
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<DownloadEvent>.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<void>.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<void>.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<DownloadEvent>.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<void>.delayed(Duration.zero);

expect(errorEntries, hasLength(1));
expect(errorEntries.first.message, contains('boom'));
});
});
}
Loading