diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml index 800532f01..744041532 100644 --- a/open_wearable/android/app/src/main/AndroidManifest.xml +++ b/open_wearable/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + diff --git a/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/AudioRecordingService.kt b/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/AudioRecordingService.kt new file mode 100644 index 000000000..480f4bbf3 --- /dev/null +++ b/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/AudioRecordingService.kt @@ -0,0 +1,64 @@ +package edu.kit.teco.openWearable + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.ResultReceiver + +/** Keeps an explicitly started microphone recording audible while the app is backgrounded. */ +class AudioRecordingService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + @Suppress("DEPRECATION") + val receiver = intent?.getParcelableExtra("result") + try { + val channelId = "audio_recording" + val manager = getSystemService(NotificationManager::class.java) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel(channelId, "Audio recording", NotificationManager.IMPORTANCE_LOW), + ) + Notification.Builder(this, channelId) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + val openApp = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = builder + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setContentTitle("OpenWearables is recording audio") + .setContentText("Open the app to stop recording.") + .setContentIntent(openApp) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + startForeground(104, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) + } else { + startForeground(104, notification) + } + receiver?.send(0, null) + } catch (error: Exception) { + receiver?.send(1, Bundle().apply { putString("error", error.message) }) + stopSelf() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE) + super.onDestroy() + } +} diff --git a/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/MainActivity.kt b/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/MainActivity.kt index 31debb31a..049496f9e 100644 --- a/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/MainActivity.kt +++ b/open_wearable/android/app/src/main/kotlin/edu/kit/teco/openwearables/MainActivity.kt @@ -1,6 +1,11 @@ package edu.kit.teco.openWearable import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver import android.provider.Settings import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -14,6 +19,35 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "edu.kit.teco.openWearable/audio_recording", + ).setMethodCallHandler { call, result -> + val intent = Intent(this, AudioRecordingService::class.java) + when (call.method) { + "start" -> { + val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(code: Int, data: Bundle?) { + if (code == 0) result.success(null) + else result.error("audio_recording_service", data?.getString("error"), null) + } + } + intent.putExtra("result", receiver) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) + else startService(intent) + } catch (error: Exception) { + result.error("audio_recording_service", error.message, null) + } + } + "stop" -> { + stopService(intent) + result.success(null) + } + else -> result.notImplemented() + } + } + MethodChannel( flutterEngine.dartExecutor.binaryMessenger, SYSTEM_SETTINGS_CHANNEL, @@ -33,4 +67,9 @@ class MainActivity : FlutterActivity() { } } } + + override fun onDestroy() { + stopService(Intent(this, AudioRecordingService::class.java)) + super.onDestroy() + } } diff --git a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart index 73f42fcff..ad35b6f96 100644 --- a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart +++ b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart @@ -7,6 +7,7 @@ import 'package:open_wearable/apps/heart_tracker/model/ppg_filter.dart'; import 'package:open_wearable/apps/heart_tracker/widgets/rowling_chart.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/devices/devices_page.dart'; import 'package:provider/provider.dart'; @@ -29,6 +30,9 @@ class HeartTrackerPage extends StatefulWidget { } class _HeartTrackerPageState extends State { + late final WearablesProvider _wearablesProvider; + Wearable? _wearable; + int _ppgTimestampExponent = -3; PpgFilter? _ppgFilter; Stream<(int, double)>? _displayPpgSignalStream; Stream? _heartRateStream; @@ -38,22 +42,61 @@ class _HeartTrackerPageState extends State { @override void initState() { super.initState(); + _wearablesProvider = context.read(); + _wearablesProvider.addListener(_updateConnection); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - _initializePipeline(); + _updateConnection(); }); } - void _initializePipeline() { - final configProvider = - Provider.of(context, listen: false); - _sensorConfigProvider = configProvider; - final ppgSensor = widget.ppgSensor; - final accelerometerSensor = widget.accelerometerSensor; - final opticalTemperatureSensor = widget.opticalTemperatureSensor; + void _updateConnection() { + final wearable = _wearablesProvider.wearables + .where((device) => device.deviceId == widget.wearable.deviceId) + .firstOrNull; + if (identical(wearable, _wearable)) { + return; + } + setState(() { + _ppgFilter?.dispose(); + _ppgFilter = null; + _displayPpgSignalStream = null; + _heartRateStream = null; + _signalQualityStream = null; + _sensorConfigProvider = null; + _wearable = wearable; + if (wearable == null) { + return; + } + final sensors = wearable.requireCapability().sensors; + Sensor? currentSensor(Sensor? previous) => sensors + .where((sensor) => sensor.sensorName == previous?.sensorName) + .firstOrNull; + final ppgSensor = currentSensor(widget.ppgSensor); + if (ppgSensor == null) { + return; + } + final configProvider = + _wearablesProvider.getSensorConfigurationProvider(wearable); + _sensorConfigProvider = configProvider; + _ppgTimestampExponent = ppgSensor.timestampExponent; + _initializePipeline( + configProvider, + ppgSensor, + currentSensor(widget.accelerometerSensor), + currentSensor(widget.opticalTemperatureSensor), + ); + }); + } + void _initializePipeline( + SensorConfigurationProvider configProvider, + Sensor ppgSensor, + Sensor? accelerometerSensor, + Sensor? opticalTemperatureSensor, + ) { final sampleFreq = _configureSensorForStreaming( ppgSensor, configProvider, @@ -135,20 +178,15 @@ class _HeartTrackerPageState extends State { timestampExponent: ppgSensor.timestampExponent, ); ppgFilter.initialize(); - if (!mounted) { - ppgFilter.dispose(); - return; - } - setState(() { - _displayPpgSignalStream = ppgFilter.displaySignalStream; - _heartRateStream = ppgFilter.heartRateStream; - _signalQualityStream = ppgFilter.signalQualityStream; - _ppgFilter = ppgFilter; - }); + _displayPpgSignalStream = ppgFilter.displaySignalStream; + _heartRateStream = ppgFilter.heartRateStream; + _signalQualityStream = ppgFilter.signalQualityStream; + _ppgFilter = ppgFilter; } @override void dispose() { + _wearablesProvider.removeListener(_updateConnection); final configProvider = _sensorConfigProvider; if (configProvider != null) { unawaited(configProvider.turnOffAllSensors()); @@ -372,16 +410,18 @@ class _HeartTrackerPageState extends State { appBar: PlatformAppBar( title: PlatformText('Heart Tracker'), ), - body: displayPpgSignalStream == null || - heartRateStream == null || - signalQualityStream == null - ? const Center(child: PlatformCircularProgressIndicator()) - : _buildContent( - context, - displayPpgSignalStream, - heartRateStream, - signalQualityStream, - ), + body: _wearable == null + ? const Center(child: Text('The selected wearable is disconnected.')) + : displayPpgSignalStream == null || + heartRateStream == null || + signalQualityStream == null + ? const Center(child: PlatformCircularProgressIndicator()) + : _buildContent( + context, + displayPpgSignalStream, + heartRateStream, + signalQualityStream, + ), ); } @@ -392,10 +432,11 @@ class _HeartTrackerPageState extends State { Stream signalQualityStream, ) { return ListView( + key: ObjectKey(_wearable), padding: const EdgeInsets.fromLTRB(12, 12, 12, 20), children: [ DeviceRow( - group: WearableDisplayGroup.single(wearable: widget.wearable), + group: WearableDisplayGroup.single(wearable: _wearable!), ), const SizedBox(height: 12), StreamBuilder( @@ -427,7 +468,7 @@ class _HeartTrackerPageState extends State { '(0.5-3.2 Hz).', icon: Icons.show_chart_rounded, chartStream: displayPpgSignalStream, - timestampExponent: widget.ppgSensor.timestampExponent, + timestampExponent: _ppgTimestampExponent, fixedMeasureMin: null, fixedMeasureMax: null, ), diff --git a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart index a1a028447..9bad35d40 100644 --- a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart +++ b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart @@ -8,12 +8,15 @@ import 'package:open_wearable/apps/posture_tracker/model/ewma.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; class EarableAttitudeTracker extends AttitudeTracker { - final SensorManager _sensorManager; - final SensorConfigurationProvider _sensorConfigurationProvider; + SensorManager? _sensorManager; + SensorConfigurationProvider? _sensorConfigurationProvider; StreamSubscription? _subscription; + final Set _activeConfigurations = {}; + bool _startedBefore = false; + bool _resumeWhenAvailable = false; @override - bool get isAvailable => true; + bool get isAvailable => _sensorManager != null; @override bool get isTracking => _subscription != null && !_subscription!.isPaused; @@ -25,19 +28,44 @@ class EarableAttitudeTracker extends AttitudeTracker { final bool _isLeft; EarableAttitudeTracker( - this._sensorManager, - this._sensorConfigurationProvider, + SensorManager sensorManager, + SensorConfigurationProvider sensorConfigurationProvider, this._isLeft, - ); + ) : _sensorManager = sensorManager, + _sensorConfigurationProvider = sensorConfigurationProvider; + + void updateConnection( + SensorManager? sensorManager, + SensorConfigurationProvider? sensorConfigurationProvider, + ) { + assert((sensorManager == null) == (sensorConfigurationProvider == null)); + if (identical(_sensorManager, sensorManager) && + identical(_sensorConfigurationProvider, sensorConfigurationProvider)) { + return; + } + final resume = isTracking || _resumeWhenAvailable; + unawaited(_subscription?.cancel()); + _subscription = null; + // The old connection is gone; do not write through its disposed provider. + _activeConfigurations.clear(); + _sensorManager = sensorManager; + _sensorConfigurationProvider = sensorConfigurationProvider; + _resumeWhenAvailable = resume && !isAvailable; + if (resume && isAvailable) { + start(); + } + notifyListeners(); + } @override void start() { - if (_subscription?.isPaused ?? false) { - _subscription?.resume(); - return; - } + if (_subscription != null) return; + final sensorManager = _sensorManager; + final sensorConfigurationProvider = _sensorConfigurationProvider; + if (sensorManager == null || sensorConfigurationProvider == null) return; + _resumeWhenAvailable = false; - final Sensor accelSensor = _sensorManager.sensors.firstWhere( + final Sensor accelSensor = sensorManager.sensors.firstWhere( (s) => s.sensorName.toLowerCase() == "accelerometer".toLowerCase(), ); @@ -45,34 +73,38 @@ class EarableAttitudeTracker extends AttitudeTracker { configurations.addAll(accelSensor.relatedConfigurations); for (final SensorConfiguration configuration in configurations) { + _activeConfigurations.add(configuration); if (configuration is ConfigurableSensorConfiguration && configuration.availableOptions.contains(StreamSensorConfigOption())) { - _sensorConfigurationProvider.addSensorConfigurationOption( + sensorConfigurationProvider.addSensorConfigurationOption( configuration, StreamSensorConfigOption(), markPending: false, ); } - List values = _sensorConfigurationProvider + List values = sensorConfigurationProvider .getSensorConfigurationValues(configuration, distinct: true); - _sensorConfigurationProvider.addSensorConfiguration( + sensorConfigurationProvider.addSensorConfiguration( configuration, values.first, markPending: false, ); configuration.setConfiguration( - _sensorConfigurationProvider + sensorConfigurationProvider .getSelectedConfigurationValue(configuration)!, ); } - calibrate( - Attitude( - roll: pi / 2 * (_isLeft ? -1 : 1), - pitch: 0.0, - yaw: 0.0, - ), - ); + if (!_startedBefore) { + calibrate( + Attitude( + roll: pi / 2 * (_isLeft ? -1 : 1), + pitch: 0.0, + yaw: 0.0, + ), + ); + _startedBefore = true; + } _subscription = accelSensor.sensorStream.listen((data) { if (data is SensorDoubleValue) { @@ -110,13 +142,21 @@ class EarableAttitudeTracker extends AttitudeTracker { @override void stop() { - _subscription?.pause(); + _resumeWhenAvailable = false; + unawaited(_subscription?.cancel()); + _subscription = null; + for (final configuration in _activeConfigurations) { + final off = configuration.offValue; + if (off != null) { + _sensorConfigurationProvider?.applyConfiguration(configuration, off); + } + } + _activeConfigurations.clear(); } @override void cancel() { stop(); - _subscription?.cancel(); super.cancel(); } } diff --git a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart index 7c62b093b..a3ae64c82 100644 --- a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart +++ b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart @@ -3,19 +3,22 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:open_wearable/apps/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracker.dart'; import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.dart'; import 'package:open_wearable/apps/posture_tracker/view/posture_roll_view.dart'; import 'package:open_wearable/apps/posture_tracker/view/settings_view.dart'; import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; class PostureTrackerView extends StatefulWidget { - final AttitudeTracker _tracker; + final EarableAttitudeTracker _tracker; + final String deviceId; - const PostureTrackerView(this._tracker, {super.key}); + const PostureTrackerView(this._tracker, {required this.deviceId, super.key}); @override State createState() => _PostureTrackerViewState(); @@ -23,17 +26,40 @@ class PostureTrackerView extends StatefulWidget { class _PostureTrackerViewState extends State { static const Color _goodPostureColor = Color(0xFF2F8F5B); - late final SensorConfigurationProvider _sensorConfigurationProvider; + late final WearablesProvider _wearablesProvider; + SensorConfigurationProvider? _sensorConfigurationProvider; @override void initState() { super.initState(); - _sensorConfigurationProvider = context.read(); + _wearablesProvider = context.read(); + _wearablesProvider.addListener(_updateTrackerConnection); + _updateTrackerConnection(); + } + + void _updateTrackerConnection() { + final wearable = _wearablesProvider.wearables + .where((device) => device.deviceId == widget.deviceId) + .firstOrNull; + if (wearable == null || + !wearable.hasCapability() || + !wearable.hasCapability()) { + _sensorConfigurationProvider = null; + widget._tracker.updateConnection(null, null); + return; + } + _sensorConfigurationProvider = + _wearablesProvider.getSensorConfigurationProvider(wearable); + widget._tracker.updateConnection( + wearable.requireCapability(), + _sensorConfigurationProvider, + ); } @override void dispose() { - unawaited(_sensorConfigurationProvider.turnOffAllSensors()); + _wearablesProvider.removeListener(_updateTrackerConnection); + unawaited(_sensorConfigurationProvider?.turnOffAllSensors()); super.dispose(); } @@ -177,7 +203,7 @@ class _PostureTrackerViewState extends State { if (!postureTrackerViewModel.isAvailable) ...[ const SizedBox(height: 4), Text( - 'No compatible OpenEarable connected.', + 'The selected wearable is disconnected.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.error, fontWeight: FontWeight.w600, diff --git a/open_wearable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart b/open_wearable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart index cc17de295..d3c9c1f52 100644 --- a/open_wearable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart +++ b/open_wearable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart @@ -16,12 +16,7 @@ class PostureTrackerViewModel with ChangeNotifier { final BadPostureReminder _badPostureReminder; bool _isDisposed = false; PostureTrackerViewModel(this._attitudeTracker, this._badPostureReminder) { - // _attitudeTracker.didChangeAvailability = (_) { - // if (!_isDisposed) { - // notifyListeners(); - // } - // }; - //TODO: Implement the didChangeAvailability function + _attitudeTracker.addListener(_onTrackerChanged); _attitudeTracker.listen((attitude) { _attitude = Attitude( @@ -32,6 +27,12 @@ class PostureTrackerViewModel with ChangeNotifier { }); } + void _onTrackerChanged() { + if (!_isDisposed) { + notifyListeners(); + } + } + void startTracking() { _attitudeTracker.start(); _badPostureReminder.start(); @@ -54,13 +55,17 @@ class PostureTrackerViewModel with ChangeNotifier { void setBadPostureSettings(BadPostureSettings settings) { _badPostureReminder.setSettings(settings); + if (!_isDisposed) { + notifyListeners(); + } } @override void dispose() { + _attitudeTracker.removeListener(_onTrackerChanged); + _isDisposed = true; stopTracking(); _attitudeTracker.cancel(); - _isDisposed = true; super.dispose(); } } diff --git a/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart b/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart index 117649718..54a1b9123 100644 --- a/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart +++ b/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart @@ -104,14 +104,14 @@ class _SealCheckMeasurementViewState extends State { final fileName = 'seal_check_${_timestampForFilename(now)}.json'; if (Platform.isAndroid) { - final dirPath = await FilePicker.getDirectoryPath(); - if (dirPath == null || dirPath.isEmpty) return null; - final String path = p.join(dirPath, fileName); - await File(path).writeAsString( - const JsonEncoder.withIndent(' ').convert(result), - flush: true, + final uri = await FilePicker.saveFile( + fileName: fileName, + mimeType: 'application/json', + bytes: Uint8List.fromList( + utf8.encode(const JsonEncoder.withIndent(' ').convert(result)), + ), ); - return path; + return uri?.toString(); } Directory? downloads; @@ -201,11 +201,13 @@ class _SealCheckMeasurementViewState extends State { if (_rightResult != null) 'right': _rightResult!.toJson(), }; - final path = - await _saveResultToDownloadsAsJson(combined); - final msg = path == null - ? 'Not saved — either not supported or you canceled.' - : 'Saved to: $path'; + String msg; + try { + final path = await _saveResultToDownloadsAsJson(combined); + msg = path == null ? 'Save canceled.' : 'Saved to: $path'; + } catch (error) { + msg = 'Could not save JSON: $error'; + } if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msg)), diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 4773b251d..0ec8353a1 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -117,6 +117,7 @@ final List _apps = [ supportedDevicePrefixes: _postureSupportedDevices, requiredCapabilities: _postureRequiredCapabilities, startApp: (wearable, sensorConfigProvider) async { + await sensorConfigProvider.turnOffAllSensors(); return PostureTrackerView( EarableAttitudeTracker( wearable.requireCapability(), @@ -125,6 +126,7 @@ final List _apps = [ await wearable.requireCapability().position == DevicePosition.left, ), + deviceId: wearable.deviceId, ); }, ), @@ -139,7 +141,7 @@ final List _apps = [ widget: SelectEarableView( supportedDevicePrefixes: _heartSupportedDevices, requiredCapabilities: _heartRequiredCapabilities, - startApp: (wearable, _) async { + startApp: (wearable, sensorConfigProvider) async { if (wearable.hasCapability()) { final sensors = wearable.requireCapability().sensors; Sensor? ppgSensor; @@ -177,6 +179,7 @@ final List _apps = [ final opticalTemperatureSensor = _findOpticalTemperatureSensor(sensors); + await sensorConfigProvider.turnOffAllSensors(); return HeartTrackerPage( wearable: wearable, ppgSensor: ppgSensor, diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index ff7105c23..195fcc45b 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -128,6 +128,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State with WidgetsBindingObserver { late final StreamSubscription _unsupportedFirmwareSub; late final StreamSubscription _wearableEventSub; + late final StreamSubscription _fotaVerificationSub; StreamSubscription? _bleAvailabilitySub; late final BluetoothAutoConnector _autoConnector; late final WearableConnector _wearableConnector; @@ -293,6 +294,7 @@ class _MyAppState extends State with WidgetsBindingObserver { navStateGetter: () => rootNavigatorKey.currentState, prefsFuture: _prefsFuture, onWearableConnected: _handleWearableConnected, + connectedWearables: () => _wearablesProvider.wearables, ); AutoConnectPreferences.autoConnectEnabledListenable.addListener( _syncAutoConnectorWithSetting, @@ -307,6 +309,16 @@ class _MyAppState extends State with WidgetsBindingObserver { } }); + // FOTA can reconnect before its success callback arms verification. + _fotaVerificationSub = FotaPostUpdateVerificationCoordinator + .instance.pendingVerificationIds + .listen((pendingIds) { + if (pendingIds.isEmpty) return; + for (final wearable in _wearablesProvider.wearables.toList()) { + unawaited(_maybeFinalizePostUpdateVerification(wearable)); + } + }); + startupRouteReadyCallback = _handleStartupRouteReady; } @@ -593,7 +605,14 @@ class _MyAppState extends State with WidgetsBindingObserver { } void _handleWearableConnected(Wearable wearable) { + final alreadyConnected = _wearablesProvider.wearables.any( + (connected) => connected.deviceId == wearable.deviceId, + ); _wearablesProvider.addWearable(wearable); + // ProxyProvider updates wait for a frame, which a locked phone does not draw. + if (!alreadyConnected) { + unawaited(_sensorRecorderProvider.addWearable(wearable)); + } _maybeFinalizePostUpdateVerification(wearable); } @@ -764,7 +783,6 @@ class _MyAppState extends State with WidgetsBindingObserver { _scheduleCloseShutdownIfNeeded(); } } else if (state == AppLifecycleState.paused) { - _autoConnector.stop(); _backgroundEnteredAt ??= DateTime.now(); if (_sensorRecorderProvider.isRecording) { _pendingCloseShutdownTimer?.cancel(); @@ -772,6 +790,7 @@ class _MyAppState extends State with WidgetsBindingObserver { _setBackgroundExecutionForShutdown(false); _setBackgroundExecutionForRecording(true); } else { + _autoConnector.stop(); _setBackgroundExecutionForRecording(false); _scheduleCloseShutdownIfNeeded(); } @@ -967,6 +986,7 @@ class _MyAppState extends State with WidgetsBindingObserver { unawaited(ConnectorSettings.dispose()); _unsupportedFirmwareSub.cancel(); _wearableEventSub.cancel(); + _fotaVerificationSub.cancel(); _bleAvailabilitySub?.cancel(); _wearableProvEventSub.cancel(); AutoConnectPreferences.autoConnectEnabledListenable.removeListener( diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index e75caed0d..c318e4179 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -217,6 +217,44 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.6.0', + eyebrow: 'OpenWearables 1.6.0', + title: 'Faster streams,\nsmoother device workflows', + summary: + 'Higher sensor throughput with firmware 2.3.0, clearer updates, and more reliable recording.', + heroDescription: + 'OpenWearables 1.6.0 supports faster Bluetooth sensor streaming with OpenEarable firmware 2.3.0. ' + 'Your sensor units and recording formats stay the same, and older supported firmware, including 2.2.9, remains compatible.', + accentColor: Color(0xFF8F6A67), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.speed_rounded, + title: 'Faster sensor streaming', + description: + 'Make the most of firmware 2.3.0 with more efficient Bluetooth sensor streaming, including while music is playing.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.system_update_rounded, + title: 'Clearer firmware updates', + description: + 'Follow upload, reset, and verification with clearer progress, a verification countdown, and improved recovery when an update fails.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.mic_rounded, + title: 'More reliable recording', + description: + 'Microphone recordings keep running in the background on Android. Recording errors are easier to understand and recover from.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.bluetooth_connected_rounded, + title: 'Smoother reconnects', + description: + 'Heart Tracker and Posture Tracker resume after the selected wearable reconnects, with improved Bluetooth connection handling.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index 41de28c86..b3291ff20 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -33,6 +33,7 @@ class BluetoothAutoConnector { WearableManager? _wearableManager; final Future prefsFuture; final void Function(Wearable wearable) onWearableConnected; + final Iterable Function()? connectedWearables; StreamSubscription? _connectSubscription; StreamSubscription? _scanSubscription; @@ -60,6 +61,7 @@ class BluetoothAutoConnector { WearableManager? wearableManager, required this.prefsFuture, required this.onWearableConnected, + this.connectedWearables, }) : _wearableManager = wearableManager; WearableManager get wearableManager => _wearableManager ??= WearableManager(); @@ -73,6 +75,8 @@ class BluetoothAutoConnector { return; } + // Repeated availability/resume events must not discard active connections. + if (_connectSubscription != null) return; final token = ++_sessionToken; _stopInternal(); _connectedDeviceIds.clear(); @@ -92,6 +96,9 @@ class BluetoothAutoConnector { _preferencesSubscription = AutoConnectPreferences.changes.listen((_) { unawaited(_syncTargetsWithPreferences(token: token, restartScan: true)); }); + for (final wearable in connectedWearables?.call() ?? []) { + _onDeviceConnected(wearable); + } _ensureScanRetryLoop(token: token); // Initiate the connection sequence @@ -184,9 +191,6 @@ class BluetoothAutoConnector { } } - bool _isAlreadyConnectedMessage(String message) => - message.toLowerCase().contains('already connected'); - Future _reloadTargetNames({ required int token, bool reloadPrefs = true, @@ -219,11 +223,50 @@ class BluetoothAutoConnector { return; } + await _reconcileSystemDevices(token); + if (token != _sessionToken) return; if (restartScan) { await _restartScanIfNeeded(); } } + // An ear reconnected by Android may no longer advertise to the scan listener. + Future _reconcileSystemDevices(int token) async { + if (_isConnecting || token != _sessionToken) return; + _isConnecting = true; + try { + final devices = await wearableManager.getSystemDevices( + checkAndRequestPermissions: false, + ); + for (final device in devices) { + if (token != _sessionToken) return; + final id = _normalizeDeviceId(device.id); + if (_connectedDeviceIds.contains(id) || + _pendingDeviceIds.contains(id) || + (_connectedNameCounts[device.name] ?? 0) >= + _requiredConnectionsForName(device.name)) { + continue; + } + try { + final wearable = await wearableManager.connectToDevice( + device, + options: {const ConnectedViaSystem()}, + ); + if (token != _sessionToken) return; + _markConnected(deviceId: wearable.deviceId, deviceName: wearable.name); + onWearableConnected(wearable); + } catch (error, stack) { + // An error (including "already connected") is not an initialized ear. + logger.w('System reconnect failed for ${device.id}: $error\n$stack'); + } + } + } catch (error, stack) { + logger.w('System device discovery failed: $error\n$stack'); + } finally { + if (token == _sessionToken) _isConnecting = false; + } + } + void _ensureScanRetryLoop({required int token}) { _scanRetryTimer?.cancel(); _scanRetryTimer = Timer.periodic(_scanRetryInterval, (timer) { @@ -389,13 +432,6 @@ class BluetoothAutoConnector { onWearableConnected(wearable); }).catchError((error, stackTrace) { final message = _deviceErrorMessageSafe(error, device); - if (_isAlreadyConnectedMessage(message)) { - _markConnected(deviceId: device.id, deviceName: device.name); - logger.i( - 'Skipping auto-connect for ${device.id}: $message', - ); - return; - } logger.w( 'Failed to connect to ${device.id}: $message\n$stackTrace', ); diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart index e1a45f9e0..ba225d10d 100644 --- a/open_wearable/lib/models/connector_settings.dart +++ b/open_wearable/lib/models/connector_settings.dart @@ -150,7 +150,7 @@ class ConnectorSettings { /// Stops the running server and resets the runtime status. static Future dispose() async { _stopNetworkStatusRefresh(); - await _webSocketServer?.stop(); + await _webSocketServer?.dispose(); _webSocketServer = null; _setRuntimeStatus(const ConnectorRuntimeStatus.disabled()); } diff --git a/open_wearable/lib/models/connectors/commands/subscribe_command.dart b/open_wearable/lib/models/connectors/commands/subscribe_command.dart index 79bc32a27..3e6862021 100644 --- a/open_wearable/lib/models/connectors/commands/subscribe_command.dart +++ b/open_wearable/lib/models/connectors/commands/subscribe_command.dart @@ -1,5 +1,6 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; +import '../../shared_sensor_configuration.dart'; import 'command.dart'; import 'ipc_internal_param_names.dart'; import 'param_readers.dart'; @@ -59,10 +60,12 @@ class SubscribeCommand extends RuntimeCommand { args: args, ).sensorStream; case 'sensor_configuration': - return _requireCapability( - wearable: wearable, - streamName: streamName, - ).sensorConfigurationStream; + return sharedSensorConfiguration( + _requireCapability( + wearable: wearable, + streamName: streamName, + ), + ); case 'button_events': return _requireCapability( wearable: wearable, diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 9c04b9e67..c8a87a618 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -54,6 +54,16 @@ class WebSocketIpcServer implements CommandRuntime { _wearableConnector = wearableConnector, _audioPlaybackService = audioPlaybackService ?? WebsocketAudioPlaybackService() { + // Keep the inventory current even while the network endpoint is disabled. + _connectSubscription = this.wearableManager.connectStream.listen((wearable) { + _registerConnectedWearable(wearable); + _broadcastEvent( + { + 'event': 'connected', + 'wearable': _serializeWearableSummary(wearable), + }, + ); + }); for (final command in createDefaultIpcCommands(this)) { addCommand(command); } @@ -176,17 +186,23 @@ class WebSocketIpcServer implements CommandRuntime { await _scanSubscription?.cancel(); await _connectingSubscription?.cancel(); - await _connectSubscription?.cancel(); _scanSubscription = null; _connectingSubscription = null; - _connectSubscription = null; _discoveredDevicesById.clear(); - _connectedWearablesById.clear(); _advertisedHost = null; logger.i('[connector.websocket] stopped'); } + /// Release app-lifetime device tracking when this server is discarded. + Future dispose() async { + await stop(); + await _connectSubscription?.cancel(); + _connectSubscription = null; + _connectedWearablesById.clear(); + await _scanEventsController.close(); + } + /// Removes a disconnected client session from the active set. void _onClientClosed(_ClientSession client) { _clients.remove(client); @@ -464,22 +480,17 @@ class WebSocketIpcServer implements CommandRuntime { ); }); - _connectSubscription ??= wearableManager.connectStream.listen((wearable) { - _registerConnectedWearable(wearable); - _broadcastEvent( - { - 'event': 'connected', - 'wearable': _serializeWearableSummary(wearable), - }, - ); - }); + } /// Tracks a connected wearable and removes it when it disconnects. void _registerConnectedWearable(Wearable wearable) { + if (identical(_connectedWearablesById[wearable.deviceId], wearable)) return; _connectedWearablesById[wearable.deviceId] = wearable; wearable.addDisconnectListener(() { - _connectedWearablesById.remove(wearable.deviceId); + if (identical(_connectedWearablesById[wearable.deviceId], wearable)) { + _connectedWearablesById.remove(wearable.deviceId); + } }); } diff --git a/open_wearable/lib/models/firmware_version_comparison.dart b/open_wearable/lib/models/firmware_version_comparison.dart new file mode 100644 index 000000000..683af69b0 --- /dev/null +++ b/open_wearable/lib/models/firmware_version_comparison.dart @@ -0,0 +1,20 @@ +import 'package:pub_semver/pub_semver.dart'; + +/// Compare firmware precedence, ignoring transport padding and build metadata. +bool isNewerFirmwareVersion(String latest, String current) { + Version parse(String value) { + final normalized = value + .replaceAll('\x00', '') + .trim() + .replaceFirst(RegExp(r'^v(?=\d)'), ''); + final version = Version.parse(normalized); + return Version( + version.major, + version.minor, + version.patch, + pre: version.preRelease.join('.'), + ); + } + + return parse(latest) > parse(current); +} diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart index 14b262a47..34e047a9f 100644 --- a/open_wearable/lib/models/fota_post_update_verification.dart +++ b/open_wearable/lib/models/fota_post_update_verification.dart @@ -10,11 +10,13 @@ class ArmedFotaPostUpdateVerification { final String verificationId; final String wearableName; final String? sideLabel; + final DateTime deadline; const ArmedFotaPostUpdateVerification({ required this.verificationId, required this.wearableName, this.sideLabel, + required this.deadline, }); } @@ -54,17 +56,23 @@ class FotaPostUpdateVerificationResult { /// - Verification arming metadata for UI banners. /// - Verification results consumed by toasts/banners in app lifecycle logic. class FotaPostUpdateVerificationCoordinator { - FotaPostUpdateVerificationCoordinator._(); + FotaPostUpdateVerificationCoordinator(); static final FotaPostUpdateVerificationCoordinator instance = - FotaPostUpdateVerificationCoordinator._(); + FotaPostUpdateVerificationCoordinator(); - static const Duration _maxPendingAge = Duration(minutes: 20); + static const Duration _maxPendingAge = Duration(minutes: 3); final Map _pendingById = {}; final StreamController> _pendingIdsController = StreamController>.broadcast(); int _nextVerificationId = 0; + final Map _verificationTimers = {}; + final Map _resultsById = {}; + final Set _verifyingIds = {}; + + FotaPostUpdateVerificationResult? resultFor(String verificationId) => + _resultsById[verificationId]; /// Emits the current set of active verification ids whenever it changes. Stream> get pendingVerificationIds => @@ -75,11 +83,17 @@ class FotaPostUpdateVerificationCoordinator { bool isVerificationPending(String verificationId) => _pendingById.containsKey(verificationId); + void cancel(String verificationId) { + _verificationTimers.remove(verificationId)?.cancel(); + if (_pendingById.remove(verificationId) != null) _publishPendingIds(); + } + Future armFromUpdateRequest({ required FirmwareUpdateRequest request, Wearable? selectedWearable, String? preResolvedWearableName, String? preResolvedSideLabel, + Wearable? connectionBeforeReset, }) async { _cleanupExpired(); @@ -99,8 +113,7 @@ class FotaPostUpdateVerificationCoordinator { _resolveSideLabelFromName(selectedWearable?.name) ?? _resolveSideLabelFromName(request.peripheral?.name), ); - final expectedFirmwareVersion = - expectedFirmwareVersionForRequest(request); + final expectedFirmwareVersion = expectedFirmwareVersionForRequest(request); if (expectedName == null && expectedDeviceId == null) { return null; @@ -115,7 +128,7 @@ class FotaPostUpdateVerificationCoordinator { expectedSideLabel: expectedSideLabel, ); - _pendingById[verificationId] = _PendingPostUpdateVerification( + final pending = _PendingPostUpdateVerification( verificationId: verificationId, expectedWearableName: expectedName, displayWearableName: displayName, @@ -123,13 +136,19 @@ class FotaPostUpdateVerificationCoordinator { expectedSideLabel: expectedSideLabel, expectedFirmwareVersion: expectedFirmwareVersion, armedAt: DateTime.now(), + connectionBeforeReset: connectionBeforeReset, ); + _pendingById[verificationId] = pending; + _verificationTimers[verificationId] = Timer(_maxPendingAge, () { + _completeVerification(pending, timedOut: true); + }); _publishPendingIds(); return ArmedFotaPostUpdateVerification( verificationId: verificationId, wearableName: displayName ?? 'OpenEarable', sideLabel: expectedSideLabel, + deadline: pending.armedAt.add(_maxPendingAge), ); } @@ -154,44 +173,61 @@ class FotaPostUpdateVerificationCoordinator { connectedSideLabel: connectedSideLabel, ); - if (pending == null) { + // The app also checks its existing connections when verification is armed. + // A Wearable belongs to one connection; only a new connection can verify + // the reboot, including when reinstalling the same firmware version. + if (pending == null || + identical(pending.connectionBeforeReset, wearable) || + !_verifyingIds.add(pending.verificationId)) { return null; } + try { + final version = await _readNormalizedFirmwareVersion(wearable); + return _completeVerification(pending, detectedFirmwareVersion: version); + } finally { + _verifyingIds.remove(pending.verificationId); + } + } - final detectedFirmwareVersion = - await _readNormalizedFirmwareVersion(wearable); + FotaPostUpdateVerificationResult? _completeVerification( + _PendingPostUpdateVerification pending, { + String? detectedFirmwareVersion, + bool timedOut = false, + }) { + // Ignore a late read after timeout or replacement by a newer update. + if (!identical(_pendingById[pending.verificationId], pending)) return null; final expectedFirmwareVersion = pending.expectedFirmwareVersion; - - final success = expectedFirmwareVersion != null && + final success = !timedOut && + expectedFirmwareVersion != null && detectedFirmwareVersion != null && _firmwareVersionsMatch( expectedFirmwareVersion, detectedFirmwareVersion, ); - - _pendingById.remove(pending.verificationId); - _publishPendingIds(); - - final displayName = pending.displayWearableName ?? - _displayName(wearable.name) ?? - wearable.name; - final sideLabel = pending.expectedSideLabel ?? connectedSideLabel; - - return FotaPostUpdateVerificationResult( + final displayName = pending.displayWearableName ?? 'OpenEarable'; + final result = FotaPostUpdateVerificationResult( verificationId: pending.verificationId, wearableName: displayName, - sideLabel: sideLabel, + sideLabel: pending.expectedSideLabel, expectedFirmwareVersion: expectedFirmwareVersion, detectedFirmwareVersion: detectedFirmwareVersion, success: success, - message: _buildMessage( - success: success, - wearableName: displayName, - sideLabel: sideLabel, - expectedFirmwareVersion: expectedFirmwareVersion, - detectedFirmwareVersion: detectedFirmwareVersion, - ), + message: timedOut + ? 'Verification timed out for $displayName. Reconnect the earphone ' + 'and check its firmware version. The update has not been verified.' + : _buildMessage( + success: success, + wearableName: displayName, + sideLabel: pending.expectedSideLabel, + expectedFirmwareVersion: expectedFirmwareVersion, + detectedFirmwareVersion: detectedFirmwareVersion, + ), ); + _resultsById[pending.verificationId] = result; + _pendingById.remove(pending.verificationId); + _verificationTimers.remove(pending.verificationId)?.cancel(); + _publishPendingIds(); + return result; } _PendingPostUpdateVerification? _selectMatchingPending({ @@ -395,18 +431,10 @@ class FotaPostUpdateVerificationCoordinator { } final now = DateTime.now(); - final removed = []; - _pendingById.removeWhere( - (_, pending) { - final isExpired = now.difference(pending.armedAt) > _maxPendingAge; - if (isExpired) { - removed.add(pending.verificationId); - } - return isExpired; - }, - ); - if (removed.isNotEmpty) { - _publishPendingIds(); + for (final pending in _pendingById.values.toList()) { + if (now.difference(pending.armedAt) >= _maxPendingAge) { + _completeVerification(pending, timedOut: true); + } } } @@ -439,6 +467,9 @@ class FotaPostUpdateVerificationCoordinator { return false; }); + for (final id in removed) { + _verificationTimers.remove(id)?.cancel(); + } if (removed.isNotEmpty) { _publishPendingIds(); } @@ -568,6 +599,7 @@ class _PendingPostUpdateVerification { final String? expectedSideLabel; final String? expectedFirmwareVersion; final DateTime armedAt; + final Wearable? connectionBeforeReset; const _PendingPostUpdateVerification({ required this.verificationId, @@ -577,5 +609,6 @@ class _PendingPostUpdateVerification { required this.expectedSideLabel, required this.expectedFirmwareVersion, required this.armedAt, + this.connectionBeforeReset, }); } diff --git a/open_wearable/lib/models/log_file_manager.dart b/open_wearable/lib/models/log_file_manager.dart index 56a14c63d..9dc2d1cd3 100644 --- a/open_wearable/lib/models/log_file_manager.dart +++ b/open_wearable/lib/models/log_file_manager.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Directory, File; // still fine as long as we don't use it on web @@ -37,6 +38,21 @@ class _CustomAppLogFilter extends LogFilter { } } +// Both Logger instances initialize and close their output independently. +// Share one lifecycle so file rotation and flushing are only scheduled once. +class _SharedLogOutput extends MultiOutput { + _SharedLogOutput(super.outputs); + + Future? _initialization; + Future? _destruction; + + @override + Future init() => _initialization ??= super.init(); + + @override + Future destroy() => _destruction ??= super.destroy(); +} + /// Central logging service for app/runtime logs and persisted log files. /// /// Needs: @@ -54,8 +70,6 @@ class LogFileManager with ChangeNotifier { final Logger _logger; final Logger _libLogger; - // On web this will be null and never used. - final LogOutput? _fileOutput; final String logDirectoryPath; Logger get logger => _logger; @@ -64,11 +78,9 @@ class LogFileManager with ChangeNotifier { LogFileManager._({ required Logger logger, required Logger libLogger, - required LogOutput? fileOutput, required this.logDirectoryPath, }) : _logger = logger, - _libLogger = libLogger, - _fileOutput = fileOutput; + _libLogger = libLogger; /// Async factory – call this once at startup. static Future create() async { @@ -88,7 +100,6 @@ class LogFileManager with ChangeNotifier { final appFilter = _CustomAppLogFilter(level); final libFilter = _CustomLibLogFilter(level); - LogOutput? fileOutput; String logDirPath = ''; // ------------------------ @@ -112,12 +123,10 @@ class LogFileManager with ChangeNotifier { maxRotatedFilesCount: 5, ); - fileOutput = advanced; outputs.add(advanced); } - final sharedOutput = - outputs.length == 1 ? outputs.first : MultiOutput(outputs); + final sharedOutput = _SharedLogOutput(outputs); // ------------------------ // 3) Create loggers @@ -152,10 +161,11 @@ class LogFileManager with ChangeNotifier { output: sharedOutput, ); + await Future.wait([logger.init, libLogger.init]); + return LogFileManager._( logger: logger, libLogger: libLogger, - fileOutput: fileOutput, logDirectoryPath: logDirPath, ); } @@ -207,11 +217,8 @@ class LogFileManager with ChangeNotifier { @override void dispose() { - // Only AdvancedFileOutput has destroy(); LogOutput in general doesn't. - final fo = _fileOutput; - if (fo is AdvancedFileOutput) { - fo.destroy(); - } + unawaited(_logger.close()); + unawaited(_libLogger.close()); super.dispose(); } } diff --git a/open_wearable/lib/models/shared_sensor_configuration.dart b/open_wearable/lib/models/shared_sensor_configuration.dart new file mode 100644 index 000000000..50906e3fc --- /dev/null +++ b/open_wearable/lib/models/shared_sensor_configuration.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +typedef _Report = Map; +final _reports = Expando<_SharedReports>(); + +/// One hardware subscription per manager, shared by the UI and connectors. +/// New listeners receive the latest report; the last cancellation releases BLE. +Stream> + sharedSensorConfiguration(SensorConfigurationManager manager) => + (_reports[manager] ??= _SharedReports(manager)).stream; + +class _SharedReports { + _SharedReports(this.manager); + final SensorConfigurationManager manager; + final _listeners = >{}; + StreamSubscription<_Report>? _source; + Future? _cancelling; + bool _starting = false; + _Report? _latest; + + late final stream = Stream<_Report>.multi( + (listener) { + _listeners.add(listener); + if (_latest != null) listener.add(_latest!); + listener.onCancel = () { + _listeners.remove(listener); + if (_listeners.isEmpty) { + _latest = null; + if (_source != null) { + _cancelling = _source!.cancel(); + _source = null; + } + } + }; + _start(); + }, + isBroadcast: true, + ); + + Future _start() async { + if (_source != null || _starting) return; + _starting = true; + try { + // Finish cancellation before a new getter can replace the BLE listener. + if (_cancelling != null) await _cancelling; + if (_listeners.isEmpty) return; + _source = manager.sensorConfigurationStream.listen( + (report) { + _latest = Map.unmodifiable(report); + for (final listener in _listeners.toList()) { + listener.addSync(_latest!); + } + }, + onError: (Object error, StackTrace stack) { + for (final listener in _listeners.toList()) { + listener.addErrorSync(error, stack); + } + }, + onDone: () { + _source = null; + _latest = null; + final listeners = _listeners.toList(); + _listeners.clear(); + for (final listener in listeners) { + listener.closeSync(); + } + }, + ); + } finally { + _starting = false; + } + } +} diff --git a/open_wearable/lib/models/wearable_status_cache.dart b/open_wearable/lib/models/wearable_status_cache.dart index bf3553125..90d4ab5d1 100644 --- a/open_wearable/lib/models/wearable_status_cache.dart +++ b/open_wearable/lib/models/wearable_status_cache.dart @@ -90,12 +90,19 @@ class WearableStatusCache { } final stereoDevice = wearable.requireCapability(); - final future = stereoDevice.position.then((position) { - _stereoPositionByDeviceId[deviceId] = position; + late final Future future; + future = stereoDevice.position.then((position) { + // A disconnected read must not populate a newer connection's cache. + if (identical(_stereoPositionFutureByDeviceId[deviceId], future) && + position != null) { + _stereoPositionByDeviceId[deviceId] = position; + } return position; - }).catchError((Object error, StackTrace stackTrace) { - _stereoPositionFutureByDeviceId.remove(deviceId); - throw error; + }).whenComplete(() { + // Unknown positions and failures remain retryable on the next lookup. + if (identical(_stereoPositionFutureByDeviceId[deviceId], future)) { + _stereoPositionFutureByDeviceId.remove(deviceId); + } }); _stereoPositionFutureByDeviceId[deviceId] = future; diff --git a/open_wearable/lib/view_models/sensor_configuration_provider.dart b/open_wearable/lib/view_models/sensor_configuration_provider.dart index cdaf3f226..da5c2ec0c 100644 --- a/open_wearable/lib/view_models/sensor_configuration_provider.dart +++ b/open_wearable/lib/view_models/sensor_configuration_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; +import '../models/shared_sensor_configuration.dart'; /// Summary of a profile/configuration restore attempt. class SensorConfigurationRestoreResult { @@ -54,7 +55,7 @@ class SensorConfigurationProvider with ChangeNotifier { required SensorConfigurationManager sensorConfigurationManager, }) : _sensorConfigurationManager = sensorConfigurationManager { _sensorConfigurationSubscription = - _sensorConfigurationManager.sensorConfigurationStream.listen((event) { + sharedSensorConfiguration(_sensorConfigurationManager).listen((event) { final hadReceivedConfigurationReport = _hasReceivedConfigurationReport; final previousReportedConfigurations = Map.of( @@ -139,6 +140,16 @@ class SensorConfigurationProvider with ChangeNotifier { return _sensorConfigurations[sensorConfiguration]; } + void applyConfiguration( + SensorConfiguration configuration, + SensorConfigurationValue value, + ) { + // Once submitted, accept the device's actual state, including rejection. + // Edits that have not been submitted remain protected from old reports. + clearPendingChanges(onlyFor: [configuration]); + configuration.setConfiguration(value); + } + List<(SensorConfiguration, SensorConfigurationValue)> getSelectedConfigurations({ bool pendingOnly = false, diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index 50d4a9979..b24f33633 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; @@ -421,6 +422,9 @@ class SensorRecorderProvider with ChangeNotifier { } class _IoAudioInputPlatform implements AudioInputPlatform { + static const _recordingService = + MethodChannel('edu.kit.teco.openWearable/audio_recording'); + bool _recordingServiceActive = false; final AudioRecorder _audioRecorder = AudioRecorder(); StreamSubscription? _amplitudeSub; List _availableInputDevices = const []; @@ -486,6 +490,10 @@ class _IoAudioInputPlatform implements AudioInputPlatform { bitRate: 768000, numChannels: 1, device: selectedDevice, + // Android capture must coexist with music without competing for focus. + audioInterruption: Platform.isAndroid + ? AudioInterruptionMode.none + : AudioInterruptionMode.pause, ), path: _streamingPath!, ); @@ -543,6 +551,10 @@ class _IoAudioInputPlatform implements AudioInputPlatform { } final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; + if (Platform.isAndroid) { + await _recordingService.invokeMethod('start'); + _recordingServiceActive = true; + } await _audioRecorder.start( RecordConfig( encoder: encoder, @@ -550,6 +562,9 @@ class _IoAudioInputPlatform implements AudioInputPlatform { bitRate: 768000, numChannels: 1, device: selectedDevice, + audioInterruption: Platform.isAndroid + ? AudioInterruptionMode.none + : AudioInterruptionMode.pause, ), path: audioPath, ); @@ -561,6 +576,7 @@ class _IoAudioInputPlatform implements AudioInputPlatform { } catch (e) { logger.e("Failed to start audio recording: $e"); _isRecordingActive = false; + await _stopRecordingService(); return false; } } @@ -579,10 +595,23 @@ class _IoAudioInputPlatform implements AudioInputPlatform { _currentAudioPath = null; } catch (e) { logger.e("Error stopping audio recording: $e"); + } finally { + await _stopRecordingService(); } return const []; } + Future _stopRecordingService() async { + if (!_recordingServiceActive) { + return; + } + try { + await _recordingService.invokeMethod('stop'); + } finally { + _recordingServiceActive = false; + } + } + Future _inputDeviceForSource(AudioInputSource source) async { if (source.isSystemDefault) { return null; @@ -635,6 +664,7 @@ class _IoAudioInputPlatform implements AudioInputPlatform { } catch (e) { logger.e("Error stopping audio in dispose: $e"); } finally { + await _stopRecordingService(); await _audioRecorder.dispose(); } } diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index c59a0d677..86277cd9f 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/models/wearable_status_cache.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; import '../models/logger.dart'; +import '../models/firmware_version_comparison.dart'; /// Event emitted when a newer firmware version is available for a wearable. class NewFirmwareAvailableEvent extends WearableEvent { @@ -452,7 +453,7 @@ class WearablesProvider with ChangeNotifier { .getLatestFirmwareVersion() .then((version) => version.toString()); - if (firmwareImageRepository.isNewerVersion( + if (isNewerFirmwareVersion( latestVersion, currentVersion, )) { diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index 399936ff7..70c93c08b 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -281,8 +281,12 @@ class _DeviceDetailPageState extends State { title: 'Status LED', subtitle: 'Customize the status indicator behavior.', child: StatusLEDControlWidget( + key: ObjectKey(widget.device), statusLED: widget.device.requireCapability(), rgbLed: widget.device.requireCapability(), + stateReader: widget.device.hasCapability() + ? widget.device.requireCapability() + : null, ), ) else if (widget.device.hasCapability()) diff --git a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart index 29f194bd3..d9068e6b7 100644 --- a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart +++ b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart @@ -5,15 +5,28 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; class RgbControlView extends StatefulWidget { final RgbLed rgbLed; + final Color initialColor; - const RgbControlView({super.key, required this.rgbLed}); + const RgbControlView({ + super.key, + required this.rgbLed, + this.initialColor = Colors.black, + }); @override State createState() => _RgbControlViewState(); } class _RgbControlViewState extends State { - Color _currentColor = Colors.black; + late Color _currentColor = widget.initialColor; + + @override + void didUpdateWidget(covariant RgbControlView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialColor != widget.initialColor) { + _currentColor = widget.initialColor; + } + } void _showColorPickerDialog() { showDialog( diff --git a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart index 2092a6de2..17f69f8a2 100644 --- a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart @@ -6,10 +6,12 @@ import 'rgb_control.dart'; class StatusLEDControlWidget extends StatefulWidget { final StatusLed statusLED; final RgbLed rgbLed; + final LedStateReader? stateReader; const StatusLEDControlWidget({ super.key, required this.statusLED, required this.rgbLed, + this.stateReader, }); @override @@ -19,59 +21,71 @@ class StatusLEDControlWidget extends StatefulWidget { class _StatusLEDControlWidgetState extends State { bool _overrideColor = false; bool _disableLed = false; + bool _busy = false; + Color _ledColor = Colors.black; - Future _setLedBlack() async { - try { - await widget.statusLED.showStatus(false); - await widget.rgbLed.writeLedColor(r: 0, g: 0, b: 0); - } catch (_) { - // LED control is best-effort and should not interrupt UI interactions. + @override + void initState() { + super.initState(); + if (widget.stateReader != null) { + _busy = true; + _loadInitialState(); } } - Future _resetLedOverride() async { + Future _loadInitialState() async { try { - await widget.statusLED.showStatus(true); + await _readState(); } catch (_) { - // LED control is best-effort and should not interrupt UI interactions. + // Keep the existing controls available if readback fails. + } finally { + if (mounted) setState(() => _busy = false); } } - Future _onDisableLedChanged(bool value) async { + Future _readState({bool keepColorEditor = false}) async { + final reader = widget.stateReader; + if (reader == null) return; + final state = await reader.readLedState(); + if (!mounted) return; setState(() { - _disableLed = value; - if (value) { - _overrideColor = false; - } + _disableLed = !state.showStatus && state.isBlack && !keepColorEditor; + _overrideColor = !state.showStatus && !_disableLed; + _ledColor = Color.fromARGB(255, state.red, state.green, state.blue); }); - - if (value) { - await _setLedBlack(); - return; - } - await _resetLedOverride(); } - Future _onOverrideChanged(bool value) async { - setState(() { - _overrideColor = value; - if (value) { - _disableLed = false; - } - }); - - if (value) { + Future _applyState( + {required bool disable, required bool override,}) async { + if (_busy) return; + setState(() => _busy = true); + try { + await widget.statusLED.showStatus(!disable && !override); + if (disable) await widget.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + if (!mounted) return; + setState(() { + _disableLed = disable; + _overrideColor = override; + }); + // A black override also means "disabled" on the device. Keep the color + // picker available while the user is choosing an override color. + await _readState(keepColorEditor: override); + } catch (_) { + // A partial write may have changed the mode but not the color. try { - await widget.statusLED.showStatus(false); - } catch (_) { - // LED control is best-effort and should not interrupt UI interactions. - } - return; + await _readState(keepColorEditor: _overrideColor); + } catch (_) {} + } finally { + if (mounted) setState(() => _busy = false); } - - await _resetLedOverride(); } + Future _onDisableLedChanged(bool value) => + _applyState(disable: value, override: false); + + Future _onOverrideChanged(bool value) => + _applyState(disable: false, override: value); + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -106,7 +120,7 @@ class _StatusLEDControlWidgetState extends State { const SizedBox(width: 10), Switch.adaptive( value: _disableLed, - onChanged: _onDisableLedChanged, + onChanged: _busy ? null : _onDisableLedChanged, ), ], ), @@ -137,7 +151,7 @@ class _StatusLEDControlWidgetState extends State { const SizedBox(width: 10), Switch.adaptive( value: _overrideColor, - onChanged: _onOverrideChanged, + onChanged: _busy ? null : _onOverrideChanged, ), ], ), @@ -175,7 +189,10 @@ class _StatusLEDControlWidgetState extends State { ), ), ), - RgbControlView(rgbLed: widget.rgbLed), + RgbControlView( + rgbLed: widget.rgbLed, + initialColor: _ledColor, + ), ], ), ), diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index 65fa53a1f..b28bee949 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -359,7 +359,7 @@ class _FirmwareListState extends State { size: 18, ), label: Text( - _expanded ? 'Hide Older Versions' : 'Show Older Versions', + _expanded ? 'Show fewer versions' : 'Show all versions', ), ), ), diff --git a/open_wearable/lib/widgets/fota/firmware_update.dart b/open_wearable/lib/widgets/fota/firmware_update.dart index 58e2e064c..c2fc1bb95 100644 --- a/open_wearable/lib/widgets/fota/firmware_update.dart +++ b/open_wearable/lib/widgets/fota/firmware_update.dart @@ -134,7 +134,9 @@ class _FirmwareUpdateWidgetState extends State { ), const SizedBox(height: 4), Text( - 'Firmware update is running. Do not close the app until it finishes.', + _isUpdateRunning + ? 'Firmware update is running. Do not close the app until it finishes.' + : 'Firmware update finished. See the result below.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/open_wearable/lib/widgets/fota/fota_slots_page.dart b/open_wearable/lib/widgets/fota/fota_slots_page.dart index 21f715eec..3117fa996 100644 --- a/open_wearable/lib/widgets/fota/fota_slots_page.dart +++ b/open_wearable/lib/widgets/fota/fota_slots_page.dart @@ -153,7 +153,7 @@ class _FotaSlotsPageState extends State { /// Returns the raw mcumgr erase channel for the slot. int? _eraseChannelFor(FirmwareSlotInfo slot) { - return slot.image == 0 ? null : slot.image; + return slot.image * 2 + slot.slot; } /// Opens the external mcumgr web UI that can help erase image slots. diff --git a/open_wearable/lib/widgets/fota/fota_verification_banner.dart b/open_wearable/lib/widgets/fota/fota_verification_banner.dart index 4e265394e..6d64aad39 100644 --- a/open_wearable/lib/widgets/fota/fota_verification_banner.dart +++ b/open_wearable/lib/widgets/fota/fota_verification_banner.dart @@ -76,93 +76,23 @@ class _FotaVerificationBannerState extends State { @override Widget build(BuildContext context) { - const successBackground = Color(0xFFE8F5E9); - const successForeground = Color(0xFF1E6A3A); const warningBackground = Color(0xFFFFECEC); const warningForeground = Color(0xFF8A1C1C); - final successTextStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( - color: successForeground, - fontWeight: FontWeight.w700, - ); final warningTextStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( color: warningForeground, fontWeight: FontWeight.w700, ); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - AppBanner( - backgroundColor: successBackground, - foregroundColor: successForeground, - leadingIcon: Icons.verified_rounded, - content: Text.rich( - TextSpan( - style: successTextStyle, - children: [ - const TextSpan( - text: 'Firmware upload completed successfully for ', - ), - TextSpan(text: widget.wearableName), - if (widget.sideLabel != null) const TextSpan(text: ' '), - if (widget.sideLabel != null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _FotaSideBadge( - sideLabel: widget.sideLabel!, - accentColor: successForeground, - ), - ), - const TextSpan(text: '.'), - ], - ), - ), - ), - const SizedBox(height: 8), - AppBanner( - backgroundColor: warningBackground, - foregroundColor: warningForeground, - leadingIcon: Icons.warning_amber_rounded, - content: Text( - 'Verification in progress, do not reset or power off the device: ${_format(remaining)}.', - softWrap: true, - style: warningTextStyle, - ), - ), - ], - ); - } -} - -class _FotaSideBadge extends StatelessWidget { - final String sideLabel; - final Color accentColor; - - const _FotaSideBadge({ - required this.sideLabel, - required this.accentColor, - }); - - @override - Widget build(BuildContext context) { - final foreground = accentColor; - final background = foreground.withValues(alpha: 0.16); - final border = foreground.withValues(alpha: 0.34); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: border), - ), - child: Text( - sideLabel, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: foreground, - fontWeight: FontWeight.w700, - ), + return AppBanner( + backgroundColor: warningBackground, + foregroundColor: warningForeground, + leadingIcon: Icons.warning_amber_rounded, + content: Text( + 'Verification in progress for ${widget.wearableName}' + '${widget.sideLabel == null ? '' : ' (${widget.sideLabel})'}, ' + 'do not reset or power off the device: ${_format(remaining)}.', + softWrap: true, + style: warningTextStyle, ), ); } @@ -186,6 +116,7 @@ void showFotaVerificationBanner( required String wearableName, String? sideLabel, Duration duration = const Duration(minutes: 3), + DateTime? deadline, }) { final controller = Provider.of(context, listen: false); _pruneMissingFotaVerificationBannerKeys(controller); @@ -200,9 +131,9 @@ void showFotaVerificationBanner( } } _activeFotaVerificationBannerKeys.remove(verificationId); - final deadline = _fotaVerificationDeadlinesById.putIfAbsent( + final verificationDeadline = _fotaVerificationDeadlinesById.putIfAbsent( verificationId, - () => DateTime.now().add(duration), + () => deadline ?? DateTime.now().add(duration), ); controller.showBanner( @@ -214,7 +145,7 @@ void showFotaVerificationBanner( key: bannerKey, content: FotaVerificationBanner( key: ValueKey('fota_verification_$verificationId'), - deadline: deadline, + deadline: verificationDeadline, wearableName: wearableName, sideLabel: sideLabel, onDismiss: () => _dismissFotaVerificationBanner( diff --git a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart index ac84ca54a..a326d1872 100644 --- a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart +++ b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart @@ -3,8 +3,9 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; class LoggerScreen extends StatelessWidget { - const LoggerScreen({required this.logger, super.key}); + const LoggerScreen({required this.logger, this.logSnapshot, super.key}); final FirmwareUpdateLogger logger; + final Future>? logSnapshot; @override Widget build(BuildContext context) { @@ -18,7 +19,7 @@ class LoggerScreen extends StatelessWidget { Widget _logFutureBuilder() { return FutureBuilder>( - future: logger.readLogs(), + future: logSnapshot ?? logger.readLogs(), builder: (context, snapshot) { if (snapshot.hasData) { final messages = (snapshot.data ?? const []) diff --git a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart index 97a228307..2ce295494 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; -import 'package:open_wearable/widgets/app_banner.dart'; import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; import '../logger_screen/logger_screen.dart'; @@ -35,8 +35,12 @@ class _UpdateStepViewState extends State { bool _lastReportedRunning = false; bool _startRequested = false; - bool _verificationBannerShown = false; + bool _verificationStarted = false; + bool _verificationCancelled = false; + ArmedFotaPostUpdateVerification? _verification; bool _isVerificationPending = false; + FotaPostUpdateVerificationResult? _verificationResult; + Future>? _recoveryLogSnapshot; bool _loopWarningHandled = false; String? _lastResetValidateStage; StreamSubscription>? _verificationPendingSubscription; @@ -94,37 +98,11 @@ class _UpdateStepViewState extends State { listener: (context, state) async { _reportRunningState(_isUpdateInProgress(state)); final updateProvider = context.read(); + unawaited(_updateVerification(state, updateProvider)); await _maybeShowLoopWarning( state: state, updateProvider: updateProvider, ); - if (state is UpdateFirmwareStateHistory && - state.isComplete && - state.history.isNotEmpty && - state.history.last is UpdateCompleteSuccess) { - if (_verificationBannerShown) { - return; - } - _verificationBannerShown = true; - final armedVerification = await FotaPostUpdateVerificationCoordinator - .instance - .armFromUpdateRequest( - request: updateProvider.updateParameters, - selectedWearable: updateProvider.selectedWearable, - preResolvedWearableName: widget.preResolvedWearableName, - preResolvedSideLabel: widget.preResolvedSideLabel, - ); - if (!mounted || armedVerification == null) { - return; - } - _bindVerificationLifecycle(armedVerification.verificationId); - showFotaVerificationBanner( - this.context, - verificationId: armedVerification.verificationId, - wearableName: armedVerification.wearableName, - sideLabel: armedVerification.sideLabel, - ); - } }, builder: (context, state) { return switch (state) { @@ -136,33 +114,110 @@ class _UpdateStepViewState extends State { ); } + // The updater can also reset before uploading to clear an old pending image. + // Only the reset after Test/Confirm starts post-update verification. + bool _isVerificationReset(UpdateState state) => + state is UpdateFirmwareStateHistory && + state.currentState?.stage == 'Reset' && + state.history + .any((entry) => entry.stage == 'Test' || entry.stage == 'Confirm'); + + bool _isNativeSuccess(UpdateState state) => + state is UpdateFirmwareStateHistory && + state.isComplete && + state.history.isNotEmpty && + state.history.last is UpdateCompleteSuccess; + + // TEST_ONLY can remain in Reset for the configured 90-second swap estimate. + // A new connection reporting the expected firmware is already verified. + bool _hasVerificationResult(UpdateState state) => + _verificationResult != null && + (_isVerificationReset(state) || _isNativeSuccess(state)); + + bool _canAbortUpdate(UpdateState state) => + state is UpdateFirmwareStateHistory && + !state.isComplete && + !_isVerificationReset(state) && + !_isVerificationPending; + bool _isUpdateInProgress(UpdateState state) { - if (state is UpdateInitial) { - return false; - } + if (state is UpdateInitial || _hasVerificationResult(state)) return false; if (state is UpdateFirmwareStateHistory) { - return !state.isComplete; + return !state.isComplete || _isVerificationPending; } return true; } - /// Subscribes the page-level success panel to the coordinator entry created - /// for this update so it disappears immediately after reconnect validation. + Future _updateVerification( + UpdateState state, + FirmwareUpdateRequestProvider provider, + ) async { + final coordinator = FotaPostUpdateVerificationCoordinator.instance; + if (state is UpdateFirmwareStateHistory && + state.isComplete && + !_isNativeSuccess(state)) { + _verificationCancelled = true; + final verification = _verification; + if (verification != null) { + coordinator.cancel(verification.verificationId); + dismissFotaVerificationBannerById(context, verification.verificationId); + } + return; + } + if (_verificationStarted || + (!_isVerificationReset(state) && !_isNativeSuccess(state))) { + return; + } + _verificationStarted = true; + final verification = await coordinator.armFromUpdateRequest( + request: provider.updateParameters, + selectedWearable: provider.selectedWearable, + preResolvedWearableName: widget.preResolvedWearableName, + preResolvedSideLabel: widget.preResolvedSideLabel, + connectionBeforeReset: + _isVerificationReset(state) ? provider.selectedWearable : null, + ); + if (verification == null) return; + if (_verificationCancelled) { + coordinator.cancel(verification.verificationId); + return; + } + if (!mounted) return; + _verification = verification; + _bindVerificationLifecycle(verification.verificationId); + if (_isVerificationPending) { + showFotaVerificationBanner( + context, + verificationId: verification.verificationId, + wearableName: verification.wearableName, + sideLabel: verification.sideLabel, + deadline: verification.deadline, + ); + } + } + + /// Keeps the countdown and page completion tied to actual reconnect validation. void _bindVerificationLifecycle(String verificationId) { _verificationPendingSubscription?.cancel(); - _isVerificationPending = FotaPostUpdateVerificationCoordinator.instance - .isVerificationPending(verificationId); - _verificationPendingSubscription = FotaPostUpdateVerificationCoordinator - .instance.pendingVerificationIds - .listen((pendingIds) { - final isPending = pendingIds.contains(verificationId); - if (!mounted || _isVerificationPending == isPending) { - return; - } + final coordinator = FotaPostUpdateVerificationCoordinator.instance; + void refresh() { + if (!mounted) return; setState(() { - _isVerificationPending = isPending; + _isVerificationPending = + coordinator.isVerificationPending(verificationId); + _verificationResult = coordinator.resultFor(verificationId); }); - }); + if (!_isVerificationPending) { + dismissFotaVerificationBannerById(context, verificationId); + } + _reportRunningState( + _isUpdateInProgress(context.read().state), + ); + } + + _verificationPendingSubscription = + coordinator.pendingVerificationIds.listen((_) => refresh()); + refresh(); } /// Shows a one-time warning when the update appears to restart image uploads @@ -282,7 +337,28 @@ class _UpdateStepViewState extends State { return; } - context.read().add(AbortUpdate()); + final bloc = context.read(); + var state = bloc.state; + if (state is! UpdateFirmwareStateHistory || !state.isComplete) { + final aborted = bloc.stream.firstWhere( + (state) => state is UpdateFirmwareStateHistory && state.isComplete, + ); + bloc.add(AbortUpdate()); + state = await aborted; + } + if (!mounted) return; + + // Slot inspection disposes the native updater for this device. Preserve + // its log after cancellation and before opening the recovery page. + if (state is UpdateFirmwareStateHistory) { + _recoveryLogSnapshot = state.updateManager?.logger.readLogs(); + try { + await _recoveryLogSnapshot; + } catch (_) { + // Keep the failed future so Show Log reports the original read error. + } + } + if (!mounted) return; context.push('/fota/slots', extra: wearable); } @@ -327,7 +403,8 @@ class _UpdateStepViewState extends State { ), ); - if (!mounted || shouldAbort != true) { + // The earphone may have started rebooting while confirmation was open. + if (!mounted || shouldAbort != true || !_canAbortUpdate(updateBloc.state)) { return; } @@ -421,27 +498,39 @@ class _UpdateStepViewState extends State { ) { final history = state.history; final currentState = state.currentState; - final showSuccessMessage = state.isComplete && - history.isNotEmpty && - history.last is UpdateCompleteSuccess; + final verificationFinished = _hasVerificationResult(state); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final entry in history) ...[ + // The handler announces upload before the native updater starts it. + // Only the native upload progress belongs in the completed steps. + for (final entry in history.where( + (entry) => + entry.stage != 'Upload firmware' || + entry is UpdateProgressFirmware, + )) ...[ _historyEntry(context, entry), const SizedBox(height: 8), ], - if (currentState != null) ...[ + if (currentState != null && !verificationFinished) ...[ _currentStatePanel(context, state), const SizedBox(height: 10), ], - if (!state.isComplete) ...[ + if (_isVerificationPending) ...[ + _successPanel(context), + const SizedBox(height: 10), + ], + if (_canAbortUpdate(state)) ...[ _abortButton(context), const SizedBox(height: 10), ], - if (showSuccessMessage && _isVerificationPending) ...[ - _successPanel(context), + if (verificationFinished) ...[ + _completedStep( + context, + _verificationResult!.message, + failed: !_verificationResult!.success, + ), const SizedBox(height: 10), ], if (state.isComplete && state.updateManager?.logger != null) ...[ @@ -451,6 +540,7 @@ class _UpdateStepViewState extends State { '/view', extra: LoggerScreen( logger: state.updateManager!.logger, + logSnapshot: _recoveryLogSnapshot, ), ); }, @@ -464,8 +554,23 @@ class _UpdateStepViewState extends State { } Widget _historyEntry(BuildContext context, UpdateFirmware state) { + return _completedStep( + context, + state is UpdateCompleteFailure + ? '${state.stage}: ${state.error}' + : state.stage == 'Upload' + ? 'Upload firmware' + : state.stage, + failed: state is UpdateCompleteFailure, + ); + } + + Widget _completedStep( + BuildContext context, + String message, { + bool failed = false, + }) { final colorScheme = Theme.of(context).colorScheme; - final failed = state is UpdateCompleteFailure; final foregroundColor = failed ? colorScheme.error : _successGreen; final backgroundColor = failed ? colorScheme.errorContainer.withValues(alpha: 0.35) @@ -493,7 +598,7 @@ class _UpdateStepViewState extends State { const SizedBox(width: 8), Expanded( child: Text( - state.stage, + message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -572,11 +677,20 @@ class _UpdateStepViewState extends State { final core = currentState.imageNumber == 0 ? 'application' : 'network'; return 'Uploading $core core ${currentState.progress}%'; } - return currentState.stage; + return _isVerificationReset(state) + ? 'Reset and verify' + : currentState.stage; } Widget _successPanel(BuildContext context) { - return const _VerificationWarningPanel(); + final verification = _verification!; + return FotaVerificationBanner( + deadline: verification.deadline, + wearableName: verification.wearableName, + sideLabel: verification.sideLabel, + // The coordinator owns the timeout and replaces this with its result. + onDismiss: () {}, + ); } Widget _firmwareInfoCard(BuildContext context, SelectedFirmware firmware) { @@ -643,73 +757,6 @@ class _UpdateStepViewState extends State { } } -class _VerificationWarningPanel extends StatefulWidget { - const _VerificationWarningPanel(); - - @override - State<_VerificationWarningPanel> createState() => - _VerificationWarningPanelState(); -} - -class _VerificationWarningPanelState extends State<_VerificationWarningPanel> { - static const Duration _total = Duration(minutes: 3); - late Duration _remaining; - Timer? _timer; - - @override - void initState() { - super.initState(); - _remaining = _total; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) { - timer.cancel(); - return; - } - if (_remaining.inSeconds <= 1) { - setState(() { - _remaining = Duration.zero; - }); - timer.cancel(); - } else { - setState(() { - _remaining -= const Duration(seconds: 1); - }); - } - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - String _format(Duration duration) { - final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context) { - const warningBackground = Color(0xFFFFECEC); - const warningForeground = Color(0xFF8A1C1C); - - return AppBanner( - backgroundColor: warningBackground, - foregroundColor: warningForeground, - leadingIcon: Icons.warning_amber_rounded, - content: Text( - 'Verification in progress, do not reset or power off the device: ${_format(_remaining)}.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: warningForeground, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - enum _LoopWarningAction { ignore, openSlots, diff --git a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart index e3f15c65e..242195560 100644 --- a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart @@ -89,18 +89,55 @@ class _RecorderPrefixRowState extends State { if (result == true) { final prefix = _editPrefixController.text.trim(); + if (prefix.isEmpty || prefix.length > 63) { + await _showError('Enter a prefix between 1 and 63 characters.'); + return; + } + // The device API writes character codes directly, not UTF-8. + if (RegExp(r'[^\x20-\x7e]|[<>:"/\\|?*]').hasMatch(prefix)) { + await _showError('Use ASCII characters without <>:"/\\|?*.'); + return; + } + final failures = []; + Future write(EdgeRecorderManager manager, String label) async { + try { + await manager.setFilePrefix(prefix); + } catch (_) { + failures.add(label); + } + } + await Future.wait([ - widget.manager.setFilePrefix(prefix), + write(widget.manager, + widget.pairedManager == null ? 'device' : 'first device',), if (widget.pairedManager != null) - widget.pairedManager!.setFilePrefix(prefix), + write(widget.pairedManager!, 'paired device'), ]); if (!mounted) { return; } setState(_loadPrefix); + if (failures.isNotEmpty) { + await _showError( + 'Could not update ${failures.join(' and ')}. Check the connection and try again.',); + } } } + Future _showError(String message) => showPlatformDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Prefix not saved'), + content: Text(message), + actions: [ + PlatformDialogAction( + child: PlatformText('OK'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + @override Widget build(BuildContext context) { return FutureBuilder( diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart index faffff493..299c7a60e 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart @@ -33,6 +33,17 @@ class SensorConfigurationDetailView extends StatelessWidget { .getSensorConfigurationValues(sensorConfiguration, distinct: true) .where((value) => _isVisibleValue(value, selectedValue)) .toList(growable: false); + // Sort the displayed copy, keeping each rate's original configuration value. + if (selectableValues.every( + (value) => value is SensorFrequencyConfigurationValue, + )) { + selectableValues.sort( + (a, b) => + (a as SensorFrequencyConfigurationValue).frequencyHz.compareTo( + (b as SensorFrequencyConfigurationValue).frequencyHz, + ), + ); + } final dropdownSelection = _resolveSelection( selectableValues, selectedValue, diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 9bbd642dc..b6a8084cc 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -347,7 +347,7 @@ class SensorConfigurationView extends StatelessWidget { } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. - config.setConfiguration(value); + target.provider.applyConfiguration(config, value); } for (final entry in mirroredEntriesToApply) { @@ -356,7 +356,7 @@ class SensorConfigurationView extends StatelessWidget { if (config.name.toLowerCase().contains('microphone')) { didApplyMicrophoneConfiguration = true; } - config.setConfiguration(value); + target.mirroredProvider!.applyConfiguration(config, value); } logger.d( @@ -402,7 +402,7 @@ class SensorConfigurationView extends StatelessWidget { if (appliedSensorSettings == 0 && appliedAudioInput) { return 'Microphone setting applied.'; } - return 'Sensor settings applied.'; + return 'Sensor settings sent.'; } Widget _buildThroughputWarningBanner(BuildContext context) { diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart index a5d569105..3e01c43f8 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart @@ -69,7 +69,13 @@ class _LocalRecorderAllRecordingsPageState } Future _openFile(LocalRecorderRecordingFile file) async { - await localRecorderOpenRecordingFile(file); + try { + await localRecorderOpenRecordingFile(file); + } catch (_) { + await _showErrorDialog( + 'Could not open this recording. Use Share file to open it with another app.', + ); + } } Future _shareSelectedFolders() async { diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 7fbb964d0..78eb7bd5d 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -280,7 +280,13 @@ class _LocalRecorderViewState extends State { } Future _openRecordingFile(LocalRecorderRecordingFile file) async { - await localRecorderOpenRecordingFile(file); + try { + await localRecorderOpenRecordingFile(file); + } catch (_) { + await _showErrorDialog( + 'Could not open this recording. Use Share file to open it with another app.', + ); + } } Future _openAllRecordingsPage({required bool isRecording}) async { diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 61a2877e8..c42d3920e 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -539,10 +539,11 @@ packages: mcumgr_flutter: dependency: "direct main" description: - name: mcumgr_flutter - sha256: ad2647f188cb8ecbc5c5da1fc136e47103e1956706fdc343a5774ace797be160 - url: "https://pub.dev" - source: hosted + path: "." + ref: "93f17ded1fc2462a2cbcaa21ece781399253bf4a" + resolved-ref: "93f17ded1fc2462a2cbcaa21ece781399253bf4a" + url: "https://github.com/TobiasRoeddiger/Flutter-nRF-Connect-Device-Manager.git" + source: git version: "0.9.1" meta: dependency: transitive @@ -580,18 +581,18 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: "0134b1785a56c8076a28e5d56b618c465e28ecdf3cc3492fbda77e80776bdfbb" + sha256: fdd50765770c82b4471d4f4b90a443364c1447a34deb60db1771f6b416d26b24 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.5.0" open_earable_protocols: dependency: transitive description: name: open_earable_protocols - sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 + sha256: ca63be893d54cf8982814ee3024f524740911481aa55e14fd3b5d93f10d9a8a0 url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.4" open_file: dependency: "direct main" description: @@ -1096,11 +1097,12 @@ packages: universal_ble: dependency: "direct main" description: - name: universal_ble - sha256: ff3c4ea34e1360ba593c68f68ff2d0cd598dcb054b0de1d89c0936dbb5dd9707 - url: "https://pub.dev" - source: hosted - version: "2.0.2" + path: "." + ref: "72313c03c281f1378d3ffb669a9e5525a7a93ad3" + resolved-ref: "72313c03c281f1378d3ffb669a9e5525a7a93ad3" + url: "https://github.com/TobiasRoeddiger/universal_ble.git" + source: git + version: "2.3.0" url_launcher: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 56aedf65f..bf476f350 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.5.3 +version: 1.6.0 environment: sdk: ^3.6.0 @@ -35,8 +35,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.9 open_file: ^3.5.11 - open_earable_flutter: ^2.4.1 - universal_ble: ^2.0.4 + open_earable_flutter: ^2.5.0 + universal_ble: ^2.3.0 permission_handler: ^12.0.1 flutter_platform_widgets: ^10.0.1 provider: ^6.1.5+1 @@ -77,9 +77,17 @@ dev_dependencies: flutter_lints: ^6.0.0 dependency_overrides: + # 2.3.0 backport of Navideck/universal_ble#307; remove after a fixed release. + universal_ble: + git: + url: https://github.com/TobiasRoeddiger/universal_ble.git + ref: 72313c03c281f1378d3ffb669a9e5525a7a93ad3 file_picker: 13.1.0 - mcumgr_flutter: 0.9.1 - universal_ble: 2.0.2 + # 0.9.1 backport of nordicsemi/Flutter-nRF-Connect-Device-Manager#188. + mcumgr_flutter: + git: + url: https://github.com/TobiasRoeddiger/Flutter-nRF-Connect-Device-Manager.git + ref: 93f17ded1fc2462a2cbcaa21ece781399253bf4a # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/open_wearable/test/apps/heart_tracker/heart_tracker_reconnect_test.dart b/open_wearable/test/apps/heart_tracker/heart_tracker_reconnect_test.dart new file mode 100644 index 000000000..6da7f9b14 --- /dev/null +++ b/open_wearable/test/apps/heart_tracker/heart_tracker_reconnect_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/apps/heart_tracker/widgets/heart_tracker_page.dart'; +import 'package:open_wearable/apps/heart_tracker/widgets/rowling_chart.dart'; +import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:provider/provider.dart'; + +class _Configuration extends SensorConfiguration { + _Configuration() + : super( + name: 'PPG', + values: [SensorConfigurationValue(key: '50 Hz')], + offValue: SensorConfigurationValue(key: 'off'), + ); + final requests = []; + @override + void setConfiguration(SensorConfigurationValue value) => + requests.add(value.key); +} + +class _Ppg extends Sensor { + _Ppg(_Configuration config) + : super( + sensorName: 'PPG', + chartTitle: 'PPG', + shortChartTitle: 'PPG', + relatedConfigurations: [config], + ); + final controller = StreamController.broadcast(); + @override + List get axisNames => ['red', 'ir', 'green', 'ambient']; + @override + List get axisUnits => ['raw', 'raw', 'raw', 'raw']; + @override + Stream get sensorStream => controller.stream; + void emit(int time) => controller.add( + SensorDoubleValue(values: [100, 100, 100, 0], timestamp: time), + ); +} + +class _Ear extends Wearable + implements SensorManager, SensorConfigurationManager { + _Ear(this.deviceId) + : super( + name: 'OpenEarable', + disconnectNotifier: WearableDisconnectNotifier(), + ); + @override + final String deviceId; + final config = _Configuration(); + late final ppg = _Ppg(config); + @override + List get sensors => [ppg]; + @override + List get sensorConfigurations => [config]; + @override + Stream> + get sensorConfigurationStream => const Stream.empty(); + @override + Future disconnect() async {} +} + +class _Wearables extends WearablesProvider { + @override + final List wearables = []; + final configs = {}; + void connect(_Ear ear) { + wearables.add(ear); + configs[ear] = SensorConfigurationProvider(sensorConfigurationManager: ear); + notifyListeners(); + } + + void disconnect(_Ear ear) { + wearables.remove(ear); + configs.remove(ear)!.dispose(); + notifyListeners(); + } + + @override + SensorConfigurationProvider getSensorConfigurationProvider( + Wearable wearable, + ) => + configs[wearable]!; +} + +void main() { + testWidgets( + 'heart tracker rebinds only the selected ear and stops its current sensors', + (tester) async { + final oldEar = _Ear('selected'); + final otherEar = _Ear('other'); + final newEar = _Ear('selected'); + final wearables = _Wearables() + ..connect(oldEar) + ..connect(otherEar); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: wearables), + ChangeNotifierProvider.value( + value: wearables.getSensorConfigurationProvider(oldEar), + ), + ], + child: MaterialApp( + home: HeartTrackerPage(wearable: oldEar, ppgSensor: oldEar.ppg), + ), + ), + ); + await tester.pump(); + expect(oldEar.config.requests, ['50 Hz']); + expect(find.byType(RollingChart), findsOneWidget); + + wearables.disconnect(otherEar); + wearables.connect(otherEar); + await tester.pump(); + expect(oldEar.config.requests, ['50 Hz']); + + wearables.disconnect(oldEar); + await tester.pump(); + expect(find.text('The selected wearable is disconnected.'), findsOneWidget); + expect(find.byType(RollingChart), findsNothing); + expect(otherEar.config.requests, isEmpty); + + wearables.connect(newEar); + await tester.pump(); + expect(find.text('The selected wearable is disconnected.'), findsNothing); + expect(newEar.config.requests, ['50 Hz']); + final chart = tester.widget(find.byType(RollingChart)); + final received = []; + final subscription = + chart.dataSteam.listen((sample) => received.add(sample.$1)); + oldEar.ppg.emit(1); + newEar.ppg.emit(2); + await tester.pump(); + expect(received, [2]); + unawaited(subscription.cancel()); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(oldEar.config.requests, ['50 Hz']); + expect(newEar.config.requests, ['50 Hz', 'off']); + expect(otherEar.config.requests, isEmpty); + wearables.disconnect(newEar); + wearables.disconnect(otherEar); + wearables.dispose(); + for (final ear in [oldEar, newEar, otherEar]) { + unawaited(ear.ppg.controller.close()); + } + expect(tester.takeException(), isNull); + }); +} diff --git a/open_wearable/test/apps/posture_tracker/earable_attitude_tracker_test.dart b/open_wearable/test/apps/posture_tracker/earable_attitude_tracker_test.dart new file mode 100644 index 000000000..e5fae7013 --- /dev/null +++ b/open_wearable/test/apps/posture_tracker/earable_attitude_tracker_test.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracker.dart'; +import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.dart'; +import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart'; +import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; + +class Configuration extends SensorConfiguration { + Configuration() + : super( + name: 'IMU', + values: [SensorConfigurationValue(key: '25 Hz stream')], + offValue: SensorConfigurationValue(key: 'off'), + ); + final requests = []; + @override + void setConfiguration(SensorConfigurationValue value) => + requests.add(value.key); +} + +class Accel extends Sensor { + Accel(Configuration c) + : super( + sensorName: 'Accelerometer', + chartTitle: 'Acceleration', + shortChartTitle: 'Acc', + relatedConfigurations: [c], + ); + final controller = StreamController.broadcast(); + @override + List get axisNames => ['X', 'Y', 'Z']; + @override + List get axisUnits => ['m/s2', 'm/s2', 'm/s2']; + @override + Stream get sensorStream => controller.stream; + void emit(int time) => + controller.add(SensorDoubleValue(values: [0, 0, 9.8], timestamp: time)); +} + +class Sensors implements SensorManager { + Sensors(this.sensors); + @override + final List sensors; +} + +class Configs implements SensorConfigurationManager { + Configs(this.sensorConfigurations); + @override + final List sensorConfigurations; + @override + Stream> + get sensorConfigurationStream => const Stream.empty(); +} + +void main() { + test('reconnect uses fresh sensors and preserves posture calibration', () async { + final oldConfig = Configuration(); + final oldSensor = Accel(oldConfig); + final oldProvider = SensorConfigurationProvider( + sensorConfigurationManager: Configs([oldConfig]), + ); + final tracker = EarableAttitudeTracker( + Sensors([oldSensor]), oldProvider, true, + ); + final model = PostureTrackerViewModel( + tracker, BadPostureReminder(attitudeTracker: tracker), + ); + var changes = 0; + model.addListener(() => changes++); + model.startTracking(); + for (var i = 0; i < 64; i++) { + oldSensor.emit(i); + } + await Future.delayed(Duration.zero); + model.calibrate(); + final beforeDisconnect = changes; + tracker.updateConnection(null, null); + oldProvider.dispose(); + expect(model.isAvailable, isFalse); + expect(model.isTracking, isFalse); + expect(changes, greaterThan(beforeDisconnect)); + expect(oldConfig.requests, ['25 Hz stream']); + + final newConfig = Configuration(); + final newSensor = Accel(newConfig); + final newProvider = SensorConfigurationProvider( + sensorConfigurationManager: Configs([newConfig]), + ); + final newSensors = Sensors([newSensor]); + tracker.updateConnection(newSensors, newProvider); + tracker.updateConnection(newSensors, newProvider); + expect(model.isAvailable, isTrue); + expect(model.isTracking, isTrue); + expect(newConfig.requests, ['25 Hz stream']); + final beforeSample = changes; + oldSensor.emit(100); + newSensor.emit(101); + await Future.delayed(Duration.zero); + expect(changes, beforeSample + 1); + expect(model.attitude.roll, closeTo(0, 1e-8)); + + model.stopTracking(); + tracker.updateConnection(null, null); + tracker.updateConnection(newSensors, newProvider); + expect( + model.isTracking, + isFalse, + reason: 'A stopped tracker must not start on reconnect', + ); + expect(newConfig.requests, ['25 Hz stream', 'off']); + model.dispose(); + newProvider.dispose(); + await oldSensor.controller.close(); + await newSensor.controller.close(); + }); + + test('resuming posture does not replay readings accumulated while stopped', + () async { + final c = Configuration(); + final sensor = Accel(c); + final provider = + SensorConfigurationProvider(sensorConfigurationManager: Configs([c])); + final tracker = EarableAttitudeTracker(Sensors([sensor]), provider, true); + var received = 0; + tracker.listen((_) { + received++; + }); + tracker.start(); + sensor.emit(0); + await Future.delayed(const Duration(milliseconds: 20)); + tracker.stop(); + final before = received; + expect(c.requests, ['25 Hz stream', 'off']); + expect(tracker.isTracking, isFalse); + for (var i = 1; i <= 1000; i++) { + sensor.emit(i); + } + await Future.delayed(const Duration(milliseconds: 20)); + expect(received, before); + tracker.start(); + await Future.delayed(const Duration(milliseconds: 100)); + final replayed = received - before; + expect( + replayed, + 0, + reason: + 'Stopped samples must not be replayed as live posture after resume', + ); + expect(c.requests, ['25 Hz stream', 'off', '25 Hz stream']); + tracker.start(); + expect( + c.requests.length, + 3, + reason: 'Repeated start must not create another subscription', + ); + sensor.emit(1001); + await Future.delayed(const Duration(milliseconds: 20)); + expect(received, before + 1); + // Let the existing attitude filter settle before checking calibration. + for (var i = 0; i < 64; i++) { + sensor.emit(1002 + i); + } + await Future.delayed(const Duration(milliseconds: 20)); + tracker.calibrateToCurrentAttitude(); + sensor.emit(1066); + await Future.delayed(const Duration(milliseconds: 20)); + expect(tracker.attitude.roll, closeTo(0, 1e-8)); + tracker.stop(); + tracker.start(); + sensor.emit(1067); + await Future.delayed(const Duration(milliseconds: 20)); + expect( + tracker.attitude.roll, + closeTo(0, 1e-8), + reason: 'Resume preserves calibration', + ); + tracker.cancel(); + provider.dispose(); + await sensor.controller.close(); + }); +} diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart index 4929401b4..be6592973 100644 --- a/open_wearable/test/models/app_upgrade_registry_test.dart +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -3,7 +3,7 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; void main() { group('AppUpgradeRegistry', () { - test('registers version 1.5.0 as the latest upgrade highlight', () { + test('keeps older highlights and lists version 1.6.0 first', () { final highlight = AppUpgradeRegistry.forVersion('1.5.0'); expect(highlight, isNotNull); @@ -21,8 +21,9 @@ void main() { 'Smarter device selection', ], ); - expect(AppUpgradeRegistry.latest?.version, '1.5.0'); - expect(AppUpgradeRegistry.all.first.version, '1.5.0'); + expect(AppUpgradeRegistry.forVersion('1.6.0'), isNotNull); + expect(AppUpgradeRegistry.latest?.version, '1.6.0'); + expect(AppUpgradeRegistry.all.first.version, '1.6.0'); }); }); } diff --git a/open_wearable/test/models/bluetooth_auto_connector_test.dart b/open_wearable/test/models/bluetooth_auto_connector_test.dart new file mode 100644 index 000000000..181f3aa27 --- /dev/null +++ b/open_wearable/test/models/bluetooth_auto_connector_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/models/bluetooth_auto_connector.dart'; +import 'package:open_wearable/models/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Wearable extends Wearable { + _Wearable(this.deviceId, WearableDisconnectNotifier notifier) + : super(name: 'OpenEarable', disconnectNotifier: notifier); + @override + final String deviceId; + @override + Future disconnect() async {} +} + +class _Manager implements WearableManager { + final connections = StreamController.broadcast(sync: true); + final scans = StreamController.broadcast(); + final connected = {}; + final notifiers = {}; + final attempts = []; + final connectionOptions = >[]; + bool failRight = true; + int scanStarts = 0; + @override + Stream get connectStream => connections.stream; + @override + Stream get scanStream => scans.stream; + @override + Future startScan({bool checkAndRequestPermissions = true}) async { scanStarts++; } + @override + Future> getSystemDevices( + {bool checkAndRequestPermissions = true,}) async => + ['left', 'right', 'unrelated'] + .map((id) => DiscoveredDevice( + id: id, + name: id == 'unrelated' ? 'Unknown' : 'OpenEarable', + manufacturerData: Uint8List(0), + rssi: -50, + serviceUuids: [],),) + .toList(); + @override + Future connectToDevice(DiscoveredDevice device, + {Set options = const {},}) async { + attempts.add(device.id); + connectionOptions.add(options); + if (device.id == 'right' && failRight) throw Exception('already connected'); + final notifier = WearableDisconnectNotifier(); + notifiers[device.id] = notifier; + final wearable = _Wearable(device.id, notifier); + connected[device.id] = wearable; + connections.add(wearable); + return wearable; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + initLogger(Logger(level: Level.off)); + testWidgets( + 'reconciles a missing system ear without scan events or phantom success', + (tester) async { + // The macOS test host queries Universal BLE permissions through Pigeon. + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + const BasicMessageChannel( + 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.hasPermissions', + StandardMessageCodec(),), + (_) async => [true], + ); + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + 'OpenEarable', + 'OpenEarable', + ], + }); + final prefs = await SharedPreferences.getInstance(); + final manager = _Manager(); + final delivered = []; + final connector = BluetoothAutoConnector( + navStateGetter: () => null, + wearableManager: manager, + prefsFuture: Future.value(prefs), + connectedWearables: () => manager.connected.values, + onWearableConnected: (wearable) => delivered.add(wearable.deviceId),); + connector.start(); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + expect(delivered, ['left']); + final scanStarts = manager.scanStarts; + await tester.pump(const Duration(seconds: 3)); + expect(manager.scanStarts, scanStarts); // system retry must not restart an active scan + manager.failRight = false; + await tester.pump(const Duration(seconds: 3)); + expect(delivered, ['left', 'right']); + expect(manager.attempts.where((id) => id == 'left'), hasLength(1)); + expect(manager.attempts, isNot(contains('unrelated'))); + expect(manager.connectionOptions.every((options) => options.single is ConnectedViaSystem), isTrue); + final attemptsBeforeRestart = manager.attempts.length; + connector.start(); // duplicate powered-on notification + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + expect(manager.attempts, hasLength(attemptsBeforeRestart)); + connector.stop(); + connector.start(); // resume while both ears remain connected in the app + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + expect(manager.attempts, hasLength(attemptsBeforeRestart)); + manager.connected.remove('left'); + manager.notifiers['left']!.notifyListeners(); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + expect(delivered, ['left', 'right', 'left']); + connector.stop(); + await manager.connections.close(); + await manager.scans.close(); + }); +} diff --git a/open_wearable/test/models/connectors/websocket_inventory_test.dart b/open_wearable/test/models/connectors/websocket_inventory_test.dart new file mode 100644 index 000000000..da1c177dd --- /dev/null +++ b/open_wearable/test/models/connectors/websocket_inventory_test.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'package:logger/logger.dart'; +import 'package:open_wearable/models/logger.dart'; +import 'package:open_wearable/models/connectors/websocket_audio_playback_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/connectors/websocket_ipc_server.dart'; + +class _Manager implements WearableManager { + final connections = StreamController.broadcast(sync: true); + @override + Stream get connectStream => connections.stream; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Wearable extends Wearable { + _Wearable(this.deviceId, WearableDisconnectNotifier notifier) + : super(name: 'OpenEarable', disconnectNotifier: notifier); + @override + final String deviceId; + @override + Future disconnect() async {} +} + +class _Audio implements WebsocketAudioPlaybackService { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + initLogger(Logger(level: Level.off)); + test('tracks connections and disconnects while disabled and across stops', + () async { + final manager = _Manager(); + final server = WebSocketIpcServer(wearableManager: manager, audioPlaybackService: _Audio()); + final oldNotifier = WearableDisconnectNotifier(); + final newNotifier = WearableDisconnectNotifier(); + manager.connections.add(_Wearable('left', oldNotifier)); + expect((await server.listConnected()).single['device_id'], 'left'); + await server.stop(); + expect(await server.listConnected(), hasLength(1)); + manager.connections.add(_Wearable('left', newNotifier)); + oldNotifier.notifyListeners(); + expect(await server.listConnected(), hasLength(1)); + newNotifier.notifyListeners(); + expect(await server.listConnected(), isEmpty); + await server.dispose(); + expect(manager.connections.hasListener, isFalse); + await manager.connections.close(); + }); +} diff --git a/open_wearable/test/models/firmware_version_comparison_test.dart b/open_wearable/test/models/firmware_version_comparison_test.dart new file mode 100644 index 000000000..0b04624b5 --- /dev/null +++ b/open_wearable/test/models/firmware_version_comparison_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/firmware_version_comparison.dart'; + +void main() { + test('compares released firmware against the installed development build', + () { + expect( + isNewerFirmwareVersion('2.2.9', '2.2.10-dev.60+ga99d057fa'), isFalse,); + expect( + isNewerFirmwareVersion('2.2.10', '2.2.10-dev.60+ga99d057fa'), isTrue,); + expect( + isNewerFirmwareVersion('2.2.11', '2.2.10-dev.60+ga99d057fa'), isTrue,); + }); + test('prerelease numbers are numeric and build metadata has no precedence', + () { + expect(isNewerFirmwareVersion('2.2.10-dev.10', '2.2.10-dev.9'), isTrue); + expect(isNewerFirmwareVersion('2.2.10+z', '2.2.10+a'), isFalse); + expect(isNewerFirmwareVersion(' v2.2.10\x00 ', '2.2.9'), isTrue); + expect(isNewerFirmwareVersion('2.2.9', '2.2.9'), isFalse); + }); + test('unrecognized labels fail explicitly instead of suggesting a downgrade', + () { + expect(() => isNewerFirmwareVersion('2.2.9', 'PR #123'), + throwsFormatException,); + }); +} diff --git a/open_wearable/test/models/fota_post_update_verification_test.dart b/open_wearable/test/models/fota_post_update_verification_test.dart new file mode 100644 index 000000000..08308d5dd --- /dev/null +++ b/open_wearable/test/models/fota_post_update_verification_test.dart @@ -0,0 +1,478 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:open_wearable/view_models/app_banner_controller.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/fota_post_update_verification.dart'; + +class _Capabilities implements StereoDevice, DeviceFirmwareVersion { + final DevicePosition side; + final Future Function() read; + _Capabilities(this.side, this.read); + @override + Future get position async => side; + @override + Future readDeviceFirmwareVersion() => read(); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Ear extends Wearable { + @override + final String deviceId; + _Ear(this.deviceId, DevicePosition side, Future Function() read) + : super( + name: 'OpenEarable-Pair', + disconnectNotifier: WearableDisconnectNotifier(), + ) { + final caps = _Capabilities(side, read); + registerCapability(caps); + registerCapability(caps); + } + @override + Future disconnect() async {} +} + +FirmwareUpdateRequest request(String id) => FirmwareUpdateRequest( + peripheral: SelectedPeripheral(name: 'OpenEarable-Pair', identifier: id), + firmware: RemoteFirmware( + name: '2.2.10', + version: '2.2.10', + url: 'https://example.test/fw.zip', + type: FirmwareType.multiImage, + ), + ); + +class _CompletedBloc extends UpdateBloc { + _CompletedBloc(FirmwareUpdateRequest request) + : super(firmwareUpdateRequest: request); + void show(UpdateFirmwareStateHistory state) => emit(state); + void finish() => emit( + UpdateFirmwareStateHistory( + null, + [UpdateCompleteSuccess()], + isComplete: true, + ), + ); +} + +void main() { + late FotaPostUpdateVerificationCoordinator coordinator; + setUp(() { + coordinator = FotaPostUpdateVerificationCoordinator(); + }); + for (final side in [DevicePosition.left, DevicePosition.right]) { + testWidgets('verifies $side after reconnect', (tester) async { + final id = side.toString(); + final armed = (await coordinator.armFromUpdateRequest( + request: request(id), + preResolvedSideLabel: side == DevicePosition.left ? 'L' : 'R', + ))!; + final result = await coordinator.verifyOnWearableConnected( + _Ear(id, side, () async => '2.2.10-dev.67+gtest'), + ); + expect(result!.success, isTrue); + expect(coordinator.isVerificationPending(armed.verificationId), isFalse); + expect(coordinator.resultFor(armed.verificationId), same(result)); + }); + } + testWidgets('does not verify the other ear with the same pair name', + (tester) async { + final armed = (await coordinator.armFromUpdateRequest( + request: request('right'), + preResolvedSideLabel: 'R', + ))!; + expect( + await coordinator.verifyOnWearableConnected( + _Ear('left', DevicePosition.left, () async => '2.2.10'), + ), + isNull, + ); + expect(coordinator.isVerificationPending(armed.verificationId), isTrue); + expect( + (await coordinator.verifyOnWearableConnected( + _Ear('right', DevicePosition.right, () async => '2.2.10'), + ))! + .success, + isTrue, + ); + }); + testWidgets('reports the actual mismatched version', (tester) async { + final armed = (await coordinator.armFromUpdateRequest( + request: request('mismatch'), + preResolvedSideLabel: 'L', + ))!; + final result = await coordinator.verifyOnWearableConnected( + _Ear('mismatch', DevicePosition.left, () async => '2.2.9'), + ); + expect(result!.success, isFalse); + expect(result.message, contains('Expected 2.2.10 but detected 2.2.9')); + expect(coordinator.isVerificationPending(armed.verificationId), isFalse); + }); + testWidgets('concurrent reconnect callbacks read once and complete once', + (tester) async { + final read = Completer(); + var reads = 0; + final ear = _Ear('duplicate', DevicePosition.right, () { + reads++; + return read.future; + }); + await coordinator.armFromUpdateRequest( + request: request('duplicate'), + preResolvedSideLabel: 'R', + ); + final first = coordinator.verifyOnWearableConnected(ear); + final second = coordinator.verifyOnWearableConnected(ear); + await tester.pump(); + expect(reads, 1); + read.complete('2.2.10'); + final results = await Future.wait([first, second]); + expect(results.whereType(), hasLength(1)); + }); + testWidgets('no reconnect gives an explicit timeout, not success', + (tester) async { + final armed = (await coordinator.armFromUpdateRequest( + request: request('timeout'), + preResolvedSideLabel: 'L', + ))!; + await tester.pump(const Duration(minutes: 3)); + expect(coordinator.isVerificationPending(armed.verificationId), isFalse); + final result = coordinator.resultFor(armed.verificationId)!; + expect(result.success, isFalse); + expect(result.message, contains('timed out')); + expect(result.message, contains('not been verified')); + }); + test('arming after reconnect triggers verification of the connected ear', + () async { + final ear = _Ear('early', DevicePosition.right, () async => '2.2.10'); + expect(await coordinator.verifyOnWearableConnected(ear), isNull); + // The app subscribes to pending ids as well as connection events. + final sub = coordinator.pendingVerificationIds.listen((ids) { + if (ids.isNotEmpty) unawaited(coordinator.verifyOnWearableConnected(ear)); + }); + final armed = (await coordinator.armFromUpdateRequest( + request: request('early'), + preResolvedSideLabel: 'R', + ))!; + await Future.delayed(Duration.zero); + expect(coordinator.resultFor(armed.verificationId)!.success, isTrue); + await sub.cancel(); + }); + testWidgets('update screen shows verified and timeout results', + (tester) async { + coordinator = FotaPostUpdateVerificationCoordinator.instance; + for (final timeout in [false, true]) { + final provider = FirmwareUpdateRequestProvider(); + provider.updateParameters.peripheral = + SelectedPeripheral(name: 'OpenEarable-Pair', identifier: 'screen'); + provider.setFirmware( + RemoteFirmware( + name: '2.2.10', + version: '2.2.10', + url: 'https://example.test/fw.zip', + type: FirmwareType.multiImage, + ), + ); + final bloc = _CompletedBloc(provider.updateParameters); + addTearDown(bloc.close); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider(create: (_) => AppBannerController()), + BlocProvider.value(value: bloc), + ], + child: const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: + UpdateStepView(autoStart: false, preResolvedSideLabel: 'R'), + ), + ), + ), + ), + ); + await tester.pump(); + Set? armedIds; + final subscription = coordinator.pendingVerificationIds.listen((ids) { + if (ids.isNotEmpty) armedIds = ids; + }); + addTearDown(subscription.cancel); + bloc.finish(); + await tester.pumpAndSettle(); + expect( + armedIds, + isNotNull, + reason: 'Verification must be armed on upload success', + ); + final id = armedIds!.single; + expect(find.textContaining('Verification in progress'), findsOneWidget); + if (timeout) { + await tester.pump(const Duration(minutes: 3)); + await tester.pumpAndSettle(); + expect(find.textContaining('Verification timed out'), findsOneWidget); + } else { + await coordinator.verifyOnWearableConnected( + _Ear('screen', DevicePosition.right, () async => '2.2.10'), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Update verified'), findsOneWidget); + } + expect(coordinator.isVerificationPending(id), isFalse); + expect(find.textContaining('Verification in progress'), findsNothing); + expect(find.textContaining('00:00'), findsNothing); + await tester.pumpWidget(const SizedBox()); + provider.dispose(); + } + }); + + for (final outcome in [ + 'verified', + 'mismatch', + 'timeout', + 'abort', + 'failure', + ]) { + testWidgets('reset shows a countdown and resolves on $outcome', + (tester) async { + coordinator = FotaPostUpdateVerificationCoordinator.instance; + final original = + _Ear('reset-$outcome', DevicePosition.right, () async => '2.3.0'); + final provider = FirmwareUpdateRequestProvider() + ..setSelectedPeripheral(original) + ..setFirmware( + RemoteFirmware( + name: '2.2.9', + version: '2.2.9', + url: 'https://example.test/fw.zip', + type: FirmwareType.multiImage, + ), + ); + final bloc = _CompletedBloc(provider.updateParameters); + final banners = AppBannerController(); + final running = []; + addTearDown(bloc.close); + addTearDown(provider.dispose); + addTearDown(banners.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: banners), + BlocProvider.value(value: bloc), + ], + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: UpdateStepView( + autoStart: false, + preResolvedSideLabel: 'R', + onUpdateRunningChanged: running.add, + ), + ), + ), + ), + ), + ); + Set? armedIds; + final sub = coordinator.pendingVerificationIds.listen((ids) { + if (ids.isNotEmpty) armedIds = ids; + }); + addTearDown(sub.cancel); + bloc.show(UpdateFirmwareStateHistory(UpdateFirmware('Validate'), [])); + await tester.pump(); + expect(armedIds, isNull, reason: 'Initial validation is before reboot'); + bloc.show(UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateFirmware('Validate'), + ]),); + await tester.pump(); + expect(armedIds, isNull, + reason: 'Recovery resets before upload must not verify',); + expect(find.textContaining('Verification in progress'), findsNothing); + expect(find.text('Abort Update'), findsOneWidget); + if (outcome == 'verified') { + bloc.show(UpdateFirmwareStateHistory(UpdateFirmware('Test'), [ + UpdateProgressFirmware('Upload', 100, 1), + ]),); + await tester.pump(); + await tester.tap(find.text('Abort Update')); + await tester.pump(const Duration(milliseconds: 300)); + } + bloc.show( + UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateProgressFirmware('Upload', 100, 1), + UpdateFirmware('Test'), + ]), + ); + await tester.pump(); + await tester.pump(); + if (outcome == 'verified') { + // A confirmation opened before reboot must no longer abort it. + await tester.tap(find.text('Abort Update').last); + await tester.pump(const Duration(milliseconds: 300)); + } + expect( + armedIds, + isNotNull, + reason: 'Verification must start at Reset, before native success', + ); + final id = armedIds!.single; + expect(find.text('Reset and verify'), findsOneWidget); + expect(find.textContaining('Verification in progress'), findsOneWidget); + expect(find.text('Abort Update'), findsNothing); + expect(find.textContaining(RegExp(r'0[23]:[0-5][0-9]')), findsOneWidget); + expect(banners.activeBanners, hasLength(1)); + expect(running.last, isTrue); + // main.dart rechecks already-connected wearables when verification arms. + expect(await coordinator.verifyOnWearableConnected(original), isNull); + expect(coordinator.isVerificationPending(id), isTrue); + final deadline = tester + .widget(find.byType(FotaVerificationBanner)) + .deadline; + bloc.show( + UpdateFirmwareStateHistory(UpdateFirmware('Reset'), [ + UpdateProgressFirmware('Upload', 100, 1), + UpdateFirmware('Test'), + UpdateFirmware('Reset'), + ]), + ); + await tester.pump(const Duration(seconds: 30)); + expect(armedIds, {id}); + expect( + tester + .widget( + find.byType(FotaVerificationBanner), + ) + .deadline, + deadline, + ); + if (outcome == 'timeout') { + await tester.pump(const Duration(seconds: 150)); + } else if (outcome == 'abort' || outcome == 'failure') { + bloc.show( + UpdateFirmwareStateHistory( + null, + [ + outcome == 'abort' + ? UpdateCompleteAborted() + : UpdateCompleteFailure('Transport failed'), + ], + isComplete: true, + ), + ); + } else { + await coordinator.verifyOnWearableConnected( + _Ear( + original.deviceId, + DevicePosition.right, + () async => outcome == 'verified' ? '2.2.9' : '2.3.0', + ), + ); + } + await tester.pumpAndSettle(); + expect(coordinator.isVerificationPending(id), isFalse); + expect(find.textContaining('Verification in progress'), findsNothing); + expect(find.text('Reset and verify'), findsNothing); + expect(find.text('Abort Update'), findsNothing); + expect(banners.activeBanners, isEmpty); + expect( + running.last, + isFalse, + reason: + 'A verified reconnect must release navigation before native success', + ); + if (outcome == 'verified') { + expect( + find.textContaining('Update verified (version 2.2.9)'), + findsOneWidget, + ); + bloc.finish(); + await tester.pumpAndSettle(); + expect(armedIds, {id}, reason: 'Late native success must not rearm'); + expect(find.textContaining('Update verified'), findsOneWidget); + } else if (outcome == 'mismatch') { + expect( + find.textContaining('Expected 2.2.9 but detected 2.3.0'), + findsOneWidget, + ); + expect(find.textContaining('Update verified'), findsNothing); + } else if (outcome == 'timeout') { + expect(find.textContaining('Verification timed out'), findsOneWidget); + expect(find.textContaining('00:00'), findsNothing); + } else { + expect(coordinator.resultFor(id), isNull); + expect( + await coordinator.verifyOnWearableConnected( + _Ear( + original.deviceId, + DevicePosition.right, + () async => '2.2.9', + ), + ), + isNull, + reason: 'Cancelled checks must not verify on a later reconnect', + ); + } + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('upload is only marked complete once native upload finishes', + (tester) async { + final provider = FirmwareUpdateRequestProvider(); + final bloc = _CompletedBloc(request('upload')); + addTearDown(bloc.close); + addTearDown(provider.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + BlocProvider.value(value: bloc), + ], + child: const MaterialApp( + home: Scaffold(body: UpdateStepView(autoStart: false)), + ), + ), + ); + final unpack = UpdateFirmware('Unpack firmware'); + final uploadStarted = UpdateFirmware('Upload firmware'); + bloc.show(UpdateFirmwareStateHistory(uploadStarted, [unpack])); + await tester.pump(); + expect(find.text('Upload firmware'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_rounded), findsOneWidget); + + for (final image in [0, 1]) { + bloc.show( + UpdateFirmwareStateHistory( + UpdateProgressFirmware('Upload', 50 + image, image), + [unpack, uploadStarted], + ), + ); + await tester.pump(); + expect(find.text('Upload firmware'), findsNothing); + expect(find.text('Abort Update'), findsOneWidget); + expect( + find.text( + 'Uploading ${image == 0 ? 'application' : 'network'} core ${50 + image}%', + ), + findsOneWidget, + ); + expect(find.byIcon(Icons.check_circle_rounded), findsOneWidget); + } + + bloc.show( + UpdateFirmwareStateHistory( + UpdateFirmware('Test'), + [unpack, uploadStarted, UpdateProgressFirmware('Upload', 100, 1)], + ), + ); + await tester.pump(); + expect(find.text('Upload firmware'), findsOneWidget); + expect(find.text('Upload'), findsNothing); + expect(find.byIcon(Icons.check_circle_rounded), findsNWidgets(2)); + }); +} diff --git a/open_wearable/test/models/log_file_manager_test.dart b/open_wearable/test/models/log_file_manager_test.dart new file mode 100644 index 000000000..412719de6 --- /dev/null +++ b/open_wearable/test/models/log_file_manager_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/log_file_manager.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('shared app and library loggers rotate a file once', () async { + final directory = + await Directory.systemTemp.createTemp('wearable-log-test-'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const channel = MethodChannel('plugins.flutter.io/path_provider'); + messenger.setMockMethodCallHandler(channel, (call) async => directory.path); + final timers = []; + final rotations = []; + final messages = []; + LogFileManager? manager; + try { + await runZoned( + () async { + manager = await LogFileManager.create(); + await Future.wait([manager!.logger.init, manager!.libLogger.init]); + final latest = File('${manager!.logDirectoryPath}/latest.log'); + await latest.writeAsString( + 'x' * (1100 * 1024), + mode: FileMode.append, + ); + for (final rotate in rotations) { + rotate(); + } + await Future.delayed(const Duration(milliseconds: 500)); + manager!.logger.w('APP-AFTER-ROTATION'); + manager!.libLogger.w('LIB-AFTER-ROTATION'); + await Future.delayed(const Duration(milliseconds: 100)); + }, + zoneSpecification: ZoneSpecification( + createPeriodicTimer: (self, parent, zone, duration, callback) { + final timer = parent.createPeriodicTimer(zone, duration, callback); + timers.add(timer); + if (duration == const Duration(minutes: 1)) { + rotations.add(() => callback(timer)); + } + return timer; + }, + print: (self, parent, zone, line) => messages.add(line), + ), + ); + final errors = + messages.where((s) => s.contains('PathNotFoundException')).toList(); + print( + 'Rotation timers: ${rotations.length}; missing-file errors: ${errors.length}', + ); + expect( + errors, + isEmpty, + reason: 'Sharing an output must not race renaming latest.log', + ); + expect( + rotations, + hasLength(1), + reason: 'One output needs one rotation lifecycle', + ); + manager!.dispose(); + await Future.wait( + [manager!.logger.close(), manager!.libLogger.close()], + ); + expect( + timers.where((timer) => timer.isActive), + isEmpty, + reason: 'Disposal must cancel every shared output timer', + ); + final files = await manager!.logFiles; + expect( + files, + hasLength(2), + reason: 'Keep one rotated file and latest.log', + ); + final contents = + (await Future.wait(files.map((file) => file.readAsString()))).join(); + expect('APP-AFTER-ROTATION'.allMatches(contents), hasLength(1)); + expect('LIB-AFTER-ROTATION'.allMatches(contents), hasLength(1)); + manager = null; + } finally { + for (final timer in timers) { + timer.cancel(); + } + if (manager != null) { + manager!.dispose(); + await Future.wait( + [manager!.logger.close(), manager!.libLogger.close()], + ); + } + messenger.setMockMethodCallHandler(channel, null); + await Future.delayed(const Duration(milliseconds: 100)); + await directory.delete(recursive: true); + } + }); +} diff --git a/open_wearable/test/models/shared_sensor_configuration_test.dart b/open_wearable/test/models/shared_sensor_configuration_test.dart new file mode 100644 index 000000000..86ff8cee8 --- /dev/null +++ b/open_wearable/test/models/shared_sensor_configuration_test.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/shared_sensor_configuration.dart'; + +typedef Report = Map; + +class _Manager implements SensorConfigurationManager { + int reads = 0; + late StreamController source; + @override + List get sensorConfigurations => []; + @override + Stream get sensorConfigurationStream { + reads++; + source = StreamController(sync: true); + return source.stream; + } +} + +void main() { + test('UI and connectors share reports, replay, and independent cancellation', + () async { + final manager = _Manager(); + final ui = []; + final remote = []; + final a = sharedSensorConfiguration(manager).listen(ui.add); + manager.source.add({}); + final b = sharedSensorConfiguration(manager).listen(remote.add); + await Future.delayed(Duration.zero); + expect(manager.reads, 1); + expect(ui, hasLength(1)); + expect(remote, hasLength(1)); + manager.source.add({}); + expect(ui, hasLength(2)); + expect(remote, hasLength(2)); + await b.cancel(); + expect(manager.source.hasListener, isTrue); + manager.source.add({}); + expect(ui, hasLength(3)); + await a.cancel(); + expect(manager.source.hasListener, isFalse); + final c = sharedSensorConfiguration(manager).listen(remote.add); + await Future.delayed(Duration.zero); + expect(manager.reads, 2); + expect(remote, hasLength(2)); // no stale replay after all listeners left + manager.source.add({}); + expect(remote, hasLength(3)); + await c.cancel(); + await manager.source.close(); + }); + test('hardware errors reach both consumers and completion closes both', + () async { + final manager = _Manager(); + final errors = []; + var done = 0; + sharedSensorConfiguration(manager) + .listen((_) {}, onError: errors.add, onDone: () => done++); + sharedSensorConfiguration(manager) + .listen((_) {}, onError: errors.add, onDone: () => done++); + manager.source.addError(StateError('BLE disconnected')); + expect(errors, hasLength(2)); + await manager.source.close(); + expect(done, 2); + }); +} diff --git a/open_wearable/test/models/wearable_status_cache_test.dart b/open_wearable/test/models/wearable_status_cache_test.dart new file mode 100644 index 000000000..ae0228126 --- /dev/null +++ b/open_wearable/test/models/wearable_status_cache_test.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/wearable_status_cache.dart'; + +class _Stereo implements StereoDevice { + Future Function() read; + _Stereo(this.read); + @override + Future get position => read(); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Wearable extends Wearable { + _Wearable(_Stereo stereo) + : super( + name: 'OpenEarable-Pair', + disconnectNotifier: WearableDisconnectNotifier(),) { + registerCapability(stereo); + } + @override + String get deviceId => 'ear'; + @override + Future disconnect() async {} +} + +void main() { + final cache = WearableStatusCache.instance; + setUp(cache.clearAll); + tearDown(cache.clearAll); + test('unknown position is retried on the next view read', () async { + var reads = 0; + final ear = _Wearable( + _Stereo(() async => ++reads == 1 ? null : DevicePosition.right),); + expect(await cache.ensureStereoPosition(ear), isNull); + expect(await cache.ensureStereoPosition(ear), DevicePosition.right); + expect(await cache.ensureStereoPosition(ear), DevicePosition.right); + expect(reads, 2); + }); + test('failed position read is retried', () async { + var reads = 0; + final ear = _Wearable(_Stereo(() async { + if (++reads == 1) throw StateError('Disconnected'); + return DevicePosition.left; + }),); + await expectLater(cache.ensureStereoPosition(ear), throwsStateError); + expect(await cache.ensureStereoPosition(ear), DevicePosition.left); + }); + test('concurrent reads share a pending request', () async { + var reads = 0; + final value = Completer(); + final ear = _Wearable(_Stereo(() { + reads++; + return value.future; + }),); + final first = cache.ensureStereoPosition(ear); + final second = cache.ensureStereoPosition(ear); + expect(identical(first, second), isTrue); + value.complete(DevicePosition.left); + expect(await first, DevicePosition.left); + expect(reads, 1); + }); + for (final lateFailure in [false, true]) { + test( + 'disconnected read cannot overwrite a reconnect (failure: $lateFailure)', + () async { + final old = Completer(); + final fresh = Completer(); + final oldEar = _Wearable(_Stereo(() => old.future)); + final newEar = _Wearable(_Stereo(() => fresh.future)); + final oldRead = cache.ensureStereoPosition(oldEar)!; + final oldResult = + lateFailure ? expectLater(oldRead, throwsStateError) : oldRead; + cache.clearDevice(oldEar.deviceId); + final newRead = cache.ensureStereoPosition(newEar); + if (lateFailure) { + old.completeError(StateError('Disconnected')); + } else { + old.complete(DevicePosition.left); + } + await oldResult; + expect(identical(cache.ensureStereoPosition(newEar), newRead), isTrue); + fresh.complete(DevicePosition.right); + expect(await newRead, DevicePosition.right); + expect( + cache.cachedStereoPositionFor(newEar.deviceId), DevicePosition.right,); + }); + } +} diff --git a/open_wearable/test/view_models/sensor_configuration_provider_test.dart b/open_wearable/test/view_models/sensor_configuration_provider_test.dart index 2e343344b..440a7fb95 100644 --- a/open_wearable/test/view_models/sensor_configuration_provider_test.dart +++ b/open_wearable/test/view_models/sensor_configuration_provider_test.dart @@ -5,6 +5,75 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; void main() { + test('accepts unchanged device state after a submitted request is rejected', + () { + final off = SensorConfigurationValue(key: 'off'); + final record = SensorConfigurationValue(key: '8 Hz record'); + final configuration = _FakeSensorConfiguration( + name: 'Temperature', + values: [off, record], + ); + final manager = _FakeSensorConfigurationManager([configuration]); + final provider = SensorConfigurationProvider( + sensorConfigurationManager: manager, + ); + addTearDown(() async { + provider.dispose(); + await manager.dispose(); + }); + + manager.emit({configuration: off}); + provider.addSensorConfiguration(configuration, record); + provider.applyConfiguration(configuration, record); + expect(configuration.requests, [record]); + + manager.emit({configuration: off}); + expect(provider.getSelectedConfigurationValue(configuration), off); + expect(provider.isConfigurationPending(configuration), isFalse); + expect(provider.isConfigurationApplied(configuration), isTrue); + }); + + test('reports for submitted settings preserve newer and unsubmitted edits', + () { + final off = SensorConfigurationValue(key: 'off'); + final stream = SensorConfigurationValue(key: '8 Hz stream'); + final record = SensorConfigurationValue(key: '8 Hz record'); + final first = _FakeSensorConfiguration( + name: 'Temperature', + values: [off, stream, record], + ); + final second = _FakeSensorConfiguration( + name: 'Pressure', + values: [off, stream, record], + ); + final manager = _FakeSensorConfigurationManager([first, second]); + final provider = SensorConfigurationProvider( + sensorConfigurationManager: manager, + ); + addTearDown(() async { + provider.dispose(); + await manager.dispose(); + }); + + manager.emit({first: stream, second: off}); + provider.addSensorConfiguration(first, record); + provider.addSensorConfiguration(second, stream); + manager.emit({first: stream, second: off}); + expect(provider.getSelectedConfigurationValue(first), record); + + provider.applyConfiguration(first, record); + manager.emit({first: stream, second: off}); + expect(provider.getSelectedConfigurationValue(first), stream); + expect(provider.getSelectedConfigurationValue(second), stream); + expect(provider.isConfigurationPending(second), isTrue); + + provider.applyConfiguration(first, record); + provider.addSensorConfiguration(first, off); + manager.emit({first: record, second: off}); + expect(provider.getSelectedConfigurationValue(first), off); + expect(provider.isConfigurationPending(first), isTrue); + }); + test('notifies when the first hardware report matches selected values', () { final value = SensorConfigurationValue(key: 'off'); final configuration = _FakeSensorConfiguration( @@ -37,13 +106,17 @@ void main() { } class _FakeSensorConfiguration extends SensorConfiguration { - const _FakeSensorConfiguration({ + _FakeSensorConfiguration({ required super.name, required super.values, }); + final List requests = []; + @override - void setConfiguration(SensorConfigurationValue configuration) {} + void setConfiguration(SensorConfigurationValue configuration) { + requests.add(configuration); + } } class _FakeSensorConfigurationManager implements SensorConfigurationManager { diff --git a/open_wearable/test/widgets/fota_recovery_log_test.dart b/open_wearable/test/widgets/fota_recovery_log_test.dart new file mode 100644 index 000000000..5348b39a7 --- /dev/null +++ b/open_wearable/test/widgets/fota_recovery_log_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/fota/fota_slots_page.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:provider/provider.dart'; + +class _Logger implements FirmwareUpdateLogger { + final snapshot = Completer>(); + bool disposed = false; + int reads = 0; + + @override + Stream get logMessageStream => const Stream.empty(); + + @override + Future> readLogs({bool clearLogs = false}) async { + reads++; + if (disposed) throw StateError('Update manager does not exist'); + return snapshot.future; + } + + @override + Future clearLogs() async {} +} + +class _Manager implements FirmwareUpdateManager { + @override + final _Logger logger = _Logger(); + final cancelCompleted = Completer(); + bool cancelStarted = false; + + @override + Future cancel() { + cancelStarted = true; + return cancelCompleted.future; + } + + @override + Future kill() async { + logger.disposed = true; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Bloc extends Cubit implements UpdateBloc { + final _Manager manager; + _Bloc(this.manager) : super(UpdateInitial()); + + void stage(String stage) => emit( + UpdateFirmwareStateHistory(UpdateFirmware(stage), []), + ); + + @override + void add(UpdateEvent event) { + if (event is AbortUpdate) { + manager.cancel().then((_) { + if (!isClosed) { + emit( + UpdateFirmwareStateHistory( + null, + [UpdateCompleteAborted()], + isComplete: true, + updateManager: manager, + ), + ); + } + }); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Ear extends Wearable implements FotaSlotInfoCapability { + final _Manager manager; + int slotReads = 0; + _Ear(this.manager) + : super(name: 'ear', disconnectNotifier: WearableDisconnectNotifier()); + + @override + String get deviceId => 'ear'; + + @override + Future disconnect() async {} + + @override + Future> readFirmwareSlots() async { + slotReads++; + // The native slot reader shares and disposes this device's update manager. + manager.logger.disposed = true; + return []; + } + + @override + Future eraseFirmwareSlot({int? channel}) async {} +} + +void main() { + for (final readFails in [false, true]) { + testWidgets('recovery preserves the log result (read fails: $readFails)', + (tester) async { + final manager = _Manager(); + final ear = _Ear(manager); + final provider = FirmwareUpdateRequestProvider() + ..setSelectedPeripheral(ear); + final bloc = _Bloc(manager); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Scaffold( + body: SingleChildScrollView( + child: UpdateStepView(autoStart: false), + ), + ), + ), + GoRoute( + path: '/fota/slots', + builder: (_, __) => FotaSlotsPage(device: ear), + ), + GoRoute(path: '/view', builder: (_, state) => state.extra! as Widget), + ], + ); + addTearDown(bloc.close); + addTearDown(() { + if (!manager.cancelCompleted.isCompleted) { + manager.cancelCompleted.complete(); + } + if (!manager.logger.snapshot.isCompleted) { + manager.logger.snapshot.complete([]); + } + }); + addTearDown(provider.dispose); + addTearDown(router.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + BlocProvider.value(value: bloc), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + for (final stage in ['Reset', 'Validate', 'Reset', 'Validate']) { + bloc.stage(stage); + await tester.pump(); + } + await tester.pump(const Duration(seconds: 1)); + await tester.tap(find.text('Open Image Slots')); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + expect(ear.slotReads, 0, reason: 'Wait for cancellation before recovery'); + expect(manager.logger.reads, 0); + expect(manager.cancelStarted, isTrue); + + manager.cancelCompleted.complete(); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 20)); + } + expect(manager.logger.reads, 1); + expect(ear.slotReads, 0, reason: 'Preserve logs before slot inspection'); + if (readFails) { + manager.logger.snapshot.completeError(StateError('Original log error')); + } else { + manager.logger.snapshot.complete([ + McuLogMessage( + 'Update cancelled; diagnostic details', + McuMgrLogCategory.dfu, + McuMgrLogLevel.info, + DateTime(2026), + ), + ]); + } + await tester.pumpAndSettle(); + expect(ear.slotReads, 1); + expect(manager.logger.disposed, isTrue); + router.pop(); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Show Log')); + await tester.tap(find.text('Show Log')); + await tester.pumpAndSettle(); + expect( + find.textContaining( + readFails + ? 'Original log error' + : 'Update cancelled; diagnostic details', + ), + findsOneWidget, + ); + expect( + find.textContaining('Update manager does not exist'), + findsNothing, + ); + expect(manager.logger.reads, 1); + }); + } +} diff --git a/open_wearable/test/widgets/fota_slots_page_test.dart b/open_wearable/test/widgets/fota_slots_page_test.dart new file mode 100644 index 000000000..2f3e0c7da --- /dev/null +++ b/open_wearable/test/widgets/fota_slots_page_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/fota/fota_slots_page.dart'; + +class _Ear extends Wearable implements FotaSlotInfoCapability { + _Ear(this.image) + : super( + name: 'OpenEarable', + disconnectNotifier: WearableDisconnectNotifier(), + ); + + final int image; + final erasedChannels = []; + + @override + String get deviceId => 'ear'; + + @override + Future disconnect() async {} + + @override + Future> readFirmwareSlots() async => [ + FirmwareSlotInfo( + image: image, + slot: 1, + version: '2.3.0', + hash: const [1], + hashString: '01', + bootable: true, + pending: false, + confirmed: false, + active: false, + permanent: false, + ), + ]; + + @override + Future eraseFirmwareSlot({int? channel}) async { + erasedChannels.add(channel); + } +} + +void main() { + for (final (image, channel) in [(0, 1), (1, 3), (2, 5)]) { + testWidgets('erasing image $image targets secondary channel $channel', + (tester) async { + final ear = _Ear(image); + await tester.pumpWidget(MaterialApp(home: FotaSlotsPage(device: ear))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Erase slot')); + await tester.tap(find.text('Erase slot')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('This erases image $image, slot 1'), + findsOneWidget, + ); + expect(ear.erasedChannels, isEmpty); + await tester.tap(find.text('Erase')); + await tester.pumpAndSettle(); + + expect(ear.erasedChannels, [channel]); + }); + } +} diff --git a/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart b/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart index 1e29048af..e9995a69c 100644 --- a/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart +++ b/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart @@ -30,18 +30,78 @@ void main() { expect(primaryManager.prefix, 'session_01'); expect(pairedManager.prefix, 'session_01'); }); + for (final prefix in ['', 'x' * 64, 'é' * 32, 'folder/name', 'bad*name']) { + testWidgets('rejects invalid prefix ${prefix.length}: $prefix', + (tester) async { + final manager = _FakeEdgeRecorderManager('original_'); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: EdgeRecorderPrefixRow(manager: manager)),),); + await tester.pumpAndSettle(); + await tester.tap(find.text('On-Device Filename Prefix')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), prefix); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(manager.writes, 0); + expect(manager.prefix, 'original_'); + expect(find.text('Prefix not saved'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('accepts 63 characters after trimming', (tester) async { + final manager = _FakeEdgeRecorderManager('old_'); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: EdgeRecorderPrefixRow(manager: manager)),),); + await tester.pumpAndSettle(); + await tester.tap(find.text('On-Device Filename Prefix')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), ' ${'x' * 63} '); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(manager.prefix, 'x' * 63); + expect(manager.writes, 1); + }); + + testWidgets('reports a paired write failure and reloads the successful side', + (tester) async { + final primary = _FakeEdgeRecorderManager('old_'); + final paired = _FakeEdgeRecorderManager('old_')..fail = true; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EdgeRecorderPrefixRow( + manager: primary, pairedManager: paired,),),),); + await tester.pumpAndSettle(); + await tester.tap(find.text('On-Device Filename Prefix')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'new_'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(primary.prefix, 'new_'); + expect(paired.prefix, 'old_'); + expect( + find.textContaining('Could not update paired device'), findsOneWidget,); + expect(tester.takeException(), isNull); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('new_'), findsOneWidget); + }); } class _FakeEdgeRecorderManager implements EdgeRecorderManager { _FakeEdgeRecorderManager(this.prefix); String prefix; + int writes = 0; + bool fail = false; @override Future get filePrefix async => prefix; @override Future setFilePrefix(String prefix) async { + writes++; + if (fail) throw StateError("disconnected"); this.prefix = prefix; } } diff --git a/open_wearable/test/widgets/status_led_widget_test.dart b/open_wearable/test/widgets/status_led_widget_test.dart new file mode 100644 index 000000000..387defa05 --- /dev/null +++ b/open_wearable/test/widgets/status_led_widget_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/devices/device_detail/rgb_control.dart'; +import 'package:open_wearable/widgets/devices/device_detail/status_led_widget.dart'; + +class TestLed implements StatusLed, RgbLed, LedStateReader { + bool status = false; + List color = [0, 0, 0]; + bool failWrites = false; + int reads = 0; + + @override + Future readLedState() async { + reads++; + return LedState( + showStatus: status, + red: color[0], + green: color[1], + blue: color[2], + ); + } + + @override + Future showStatus(bool value) async { + if (failWrites) throw StateError('Disconnected'); + status = value; + } + + @override + Future writeLedColor( + {required int r, required int g, required int b,}) async { + if (failWrites) throw StateError('Disconnected'); + color = [r, g, b]; + } +} + +void main() { + Future open(WidgetTester tester, TestLed led, + {bool readback = true,}) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatusLEDControlWidget( + key: UniqueKey(), + statusLED: led, + rgbLed: led, + stateReader: readback ? led : null, + ),),),); + await tester.pumpAndSettle(); + } + + List switches(WidgetTester tester) => tester + .widgetList( + find.byType(Switch), + ) + .map((s) => s.value) + .toList(); + + testWidgets('reopening restores disabled output and manual RGB', + (tester) async { + final led = TestLed(); + await open(tester, led); + expect(switches(tester), [true, false]); + led.color = [12, 34, 56]; + await open(tester, led); + expect(switches(tester), [false, true]); + expect( + tester.widget(find.byType(RgbControlView)).initialColor, + const Color.fromARGB(255, 12, 34, 56),); + led.status = true; + await open(tester, led); + expect(switches(tester), [false, false]); + }); + + testWidgets('black override keeps the color picker usable until reopening', + (tester) async { + final led = TestLed()..status = true; + await open(tester, led); + await tester.tap(find.byType(Switch).last); + await tester.pumpAndSettle(); + expect(switches(tester), [false, true]); + expect(find.byType(RgbControlView), findsOneWidget); + await open(tester, led); + expect(switches(tester), [true, false]); + }); + + testWidgets('failed write retains confirmed state', (tester) async { + final led = TestLed()..status = true; + await open(tester, led); + led.failWrites = true; + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(switches(tester), [false, false]); + }); + + testWidgets('older devices keep write controls without attempting readback', + (tester) async { + final led = TestLed()..status = true; + await open(tester, led, readback: false); + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(switches(tester), [true, false]); + expect(led.reads, 0); + expect(led.status, isFalse); + expect(led.color, [0, 0, 0]); + }); +}