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