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
11 changes: 7 additions & 4 deletions open_wearable/lib/models/sensor_streams.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ class SensorStreams {
static final Map<Sensor, Stream<SensorValue>> _sharedStreams = {};

static Stream<SensorValue> shared(Sensor sensor) {
return _sharedStreams.putIfAbsent(
sensor,
() => sensor.sensorStream.asBroadcastStream(),
);
return _sharedStreams.putIfAbsent(sensor, () {
return sensor.sensorStream.asBroadcastStream(
onCancel: (_) {
_sharedStreams.remove(sensor);
},
);
Comment on lines +17 to +21
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SensorStreams.shared only evicts the cached entry via onCancel. If the underlying sensor.sensorStream completes (e.g., because a device disconnects) without listeners explicitly canceling first, onCancel won’t run and _sharedStreams can retain a closed stream, causing future callers to receive an already-terminated stream. Consider wrapping the source stream so you also remove the cache entry on onDone/onError (or otherwise ensure clearForSensor is invoked on disconnect).

Copilot uses AI. Check for mistakes.
});
Comment on lines 15 to +22
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change modifies the lifecycle semantics of the shared-stream cache (eviction on cancel). There doesn’t appear to be a unit test covering SensorStreams.shared cache insertion/eviction behavior, which makes regressions in long-session/reconnect scenarios hard to catch. Consider adding a small test that verifies the same stream instance is reused while active and that the cache entry is removed after the last listener cancels.

Copilot uses AI. Check for mistakes.
}

static void clearForSensor(Sensor sensor) {
Expand Down
9 changes: 9 additions & 0 deletions open_wearable/lib/view_models/sensor_recorder_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,13 @@ class SensorRecorderProvider with ChangeNotifier {
);
}
}

@override
void dispose() {
for (final wearable in _recorders.keys.toList()) {
_disposeWearable(wearable);
}
_recorders.clear();
super.dispose();
}
}
55 changes: 46 additions & 9 deletions open_wearable/lib/view_models/wearables_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class WearablesProvider with ChangeNotifier {
final Map<Wearable, SensorConfigurationProvider>
_sensorConfigurationProviders = {};
final Set<String> _splitStereoPairKeys = {};
bool _disposed = false;

List<Wearable> get wearables => _wearables;
Map<Wearable, SensorConfigurationProvider> get sensorConfigurationProviders =>
Expand Down Expand Up @@ -161,9 +162,19 @@ class WearablesProvider with ChangeNotifier {
_wearables.any((w) => w.deviceId == wearable.deviceId);

void _emitWearableEvent(WearableEvent event) {
if (_disposed || _wearableEventController.isClosed) {
return;
}
_wearableEventController.add(event);
}

void _emitUnsupportedFirmwareEvent(UnsupportedFirmwareEvent event) {
if (_disposed || _unsupportedFirmwareEventsController.isClosed) {
return;
}
_unsupportedFirmwareEventsController.add(event);
}

void _emitWearableError({
required Wearable wearable,
required String errorMessage,
Expand All @@ -180,6 +191,9 @@ class WearablesProvider with ChangeNotifier {

void _scheduleMicrotask(FutureOr<void> Function() work) {
Future.microtask(() async {
if (_disposed) {
return;
}
try {
await work();
} catch (e, st) {
Expand Down Expand Up @@ -374,16 +388,14 @@ class WearablesProvider with ChangeNotifier {
// All good, nothing to do.
break;
case FirmwareSupportStatus.tooNew:
_unsupportedFirmwareEventsController
.add(FirmwareTooNewEvent(wearable));
_emitUnsupportedFirmwareEvent(FirmwareTooNewEvent(wearable));
break;
case FirmwareSupportStatus.unsupported:
_unsupportedFirmwareEventsController
.add(FirmwareUnsupportedEvent(wearable));
_emitUnsupportedFirmwareEvent(FirmwareUnsupportedEvent(wearable));
break;
case FirmwareSupportStatus.tooOld:
_unsupportedFirmwareEventsController
.add(FirmwareTooOldEvent(wearable));
_emitUnsupportedFirmwareEvent(FirmwareTooOldEvent(wearable));
break;
case FirmwareSupportStatus.unknown:
logger.w('Firmware support unknown for ${wearable.name}');
break;
Expand Down Expand Up @@ -419,7 +431,7 @@ class WearablesProvider with ChangeNotifier {
logger.i(
'Newer firmware available for ${(dev as Wearable).name}: $currentVersion -> $latestVersion',
);
_wearableEventController.add(
_emitWearableEvent(
NewFirmwareAvailableEvent(
wearable: dev as Wearable,
currentVersion: currentVersion,
Expand Down Expand Up @@ -483,9 +495,11 @@ class WearablesProvider with ChangeNotifier {
),
);
_wearables.remove(wearable);
_sensorConfigurationProviders.remove(wearable);
_sensorConfigurationProviders.remove(wearable)?.dispose();
_capabilitySubscriptions.remove(wearable)?.cancel();
notifyListeners();
if (!_disposed) {
notifyListeners();
}
}

SensorConfigurationProvider getSensorConfigurationProvider(
Expand Down Expand Up @@ -514,8 +528,31 @@ class WearablesProvider with ChangeNotifier {
),
);
}
if (_disposed) {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _disposed guard in _handleCapabilitiesChanged is currently after side effects (e.g., initializing sensor configs / scheduling work). If a capability event arrives during teardown or after removeWearable, this handler can recreate per-wearable state even though the provider is disposed/removed. Add the guard at the start of the method (and consider also checking the wearable is still tracked, e.g., present in _capabilitySubscriptions/_wearables) before doing any work.

Copilot uses AI. Check for mistakes.
return;
}
if (addedCapabilites.isNotEmpty) {
notifyListeners();
}
}

@override
void dispose() {
_disposed = true;
for (final sub in _capabilitySubscriptions.values) {
unawaited(sub.cancel());
}
_capabilitySubscriptions.clear();

for (final provider in _sensorConfigurationProviders.values) {
provider.dispose();
}
_sensorConfigurationProviders.clear();
_wearables.clear();
_splitStereoPairKeys.clear();

unawaited(_unsupportedFirmwareEventsController.close());
unawaited(_wearableEventController.close());
super.dispose();
}
}