Commit 604db83

derdilla <82763757+derdilla@users.noreply.github.com>
2025-07-04 11:06:35
Allow not trusting time of bluetooth devices (#568)
* add setting for controlling ble time trust * implement setting for stopping ble time trust in add entry form * implement dialog that warns of large differences between bluetooth and current time * test trustBLETime setting * test trustBLETime hint * Fix showing the specified bluetooth input in tests * implement dontShowAgain button for trustBLETime dialog * cleanup pr
1 parent e4a2dbb
Changed files (6)
app
lib
test
features
app/lib/features/bluetooth/bluetooth_input.dart
@@ -11,11 +11,11 @@ import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_failure.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
 
 /// Class for inputting measurement through bluetooth.
app/lib/features/input/forms/add_entry_form.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
 import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
@@ -9,6 +11,7 @@ import 'package:blood_pressure_app/features/input/forms/medicine_intake_form.dar
 import 'package:blood_pressure_app/features/input/forms/note_form.dart';
 import 'package:blood_pressure_app/features/input/forms/weight_form.dart';
 import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
@@ -25,6 +28,7 @@ class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
     super.initialValue,
     this.meds = const [],
     this.bluetoothCubit,
+    this.mockBleInput,
   });
 
   /// All medicines selectable.
@@ -33,9 +37,15 @@ class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
   final List<Medicine> meds;
 
   /// Function to customize [BluetoothCubit] creation.
+  ///
+  /// Works on [BluetoothInputMode.newBluetoothInputCrossPlatform].
   @visibleForTesting
   final BluetoothCubit Function()? bluetoothCubit;
 
+  /// A builder for a widget that can act as a bluetooth input.
+  @visibleForTesting
+  final Widget Function(void Function(BloodPressureRecord data))? mockBleInput;
+
   @override
   FormStateBase createState() => AddEntryFormState();
 }
@@ -212,13 +222,42 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
     }
   }
 
-  void _onExternalMeasurement(BloodPressureRecord record) => fillForm((
-    timestamp: record.time,
-    note: null,
-    record: record,
-    intake: null,
-    weight: null,
-  ));
+  /// Gets called on inputs from a bluetooth device or similar.
+  void _onExternalMeasurement(BloodPressureRecord record) {
+    final settings = context.read<Settings>();
+    if (settings.trustBLETime
+        && settings.showBLETimeTrustDialog
+        && record.time.difference(DateTime.now()).inHours.abs() > 5) {
+      unawaited(showDialog(context: context, builder: (context) => AlertDialog(
+        content: Text(AppLocalizations.of(context)!.warnBLETimeSus(
+          record.time.difference(DateTime.now()).inHours
+        )),
+        actions: [
+          ElevatedButton(
+            onPressed: () {
+              settings.showBLETimeTrustDialog = false;
+              Navigator.pop(context);
+            },
+            child: Text(AppLocalizations.of(context)!.dontShowAgain),
+          ),
+          ElevatedButton(
+            onPressed: () => Navigator.pop(context),
+            child: Text(AppLocalizations.of(context)!.btnConfirm),
+          ),
+        ],
+      )));
+    }
+
+    fillForm((
+      timestamp: settings.trustBLETime
+          ? record.time
+          : _timeForm.currentState?.save() ?? DateTime.now(),
+      note: null,
+      record: record,
+      intake: null,
+      weight: null,
+    ));
+  }
 
   @override
   Widget build(BuildContext context) {
@@ -226,6 +265,8 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
     return ListView(
       padding: const EdgeInsets.symmetric(horizontal: 8),
       children: [
+        if (widget.mockBleInput != null)
+          widget.mockBleInput!.call(_onExternalMeasurement),
         (() => switch (settings.bleInput) {
           BluetoothInputMode.disabled => SizedBox.shrink(),
           BluetoothInputMode.oldBluetoothInput => OldBluetoothInput(
app/lib/l10n/app_en.arb
@@ -553,7 +553,18 @@
   "@newBluetoothInputCrossPlatform": {},
   "bluetoothInputDesc": "The beta backend works on more devices but is less tested. The cross-platform version may work on non-android and is planned to supersede the stable implementation once mature enough.",
   "@bluetoothInputDesc": {},
-  "@bluetoothInputDesc": {},
   "tapToSelect": "Tap to select",
-  "@tapToSelect": {}
+  "@tapToSelect": {},
+  "trustBLETime": "Trust time reported by bluetooth devices",
+  "@trustBLETime": {},
+  "warnBLETimeSus": "The bluetooth device reported a time off by {hours} hours. You can disable trusting timestamps from bluetooth devices in the settings.",
+  "@warnBLETimeSus": {
+    "placeholders": {
+      "hours": {
+        "type": "int"
+      }
+    }
+  },
+  "dontShowAgain": "Don''t show again",
+  "@dontShowAgain": {}
 }
app/lib/model/storage/settings_store.dart
@@ -51,6 +51,8 @@ class Settings extends ChangeNotifier {
     BluetoothInputMode? bleInput,
     bool? weightInput,
     WeightUnit? weightUnit,
+    bool? trustBLETime,
+    bool? showBLETimeTrustDialog,
   }) {
     if (accentColor != null) _accentColor = accentColor;
     if (sysColor != null) _sysColor = sysColor;
@@ -79,6 +81,8 @@ class Settings extends ChangeNotifier {
     if (bleInput != null) _bleInput = bleInput;
     if (weightInput != null) _weightInput = weightInput;
     if (weightUnit != null) _weightUnit = weightUnit;
+    if (trustBLETime != null) _trustBLETime = trustBLETime;
+    if (showBLETimeTrustDialog != null) _showBLETimeTrustDialog = showBLETimeTrustDialog;
     _language = language; // No check here, as null is the default as well.
   }
 
@@ -114,6 +118,8 @@ class Settings extends ChangeNotifier {
       weightInput: ConvertUtil.parseBool(map['weightInput']),
       preferredPressureUnit: PressureUnit.decode(ConvertUtil.parseInt(map['preferredPressureUnit'])),
       weightUnit: WeightUnit.deserialize(ConvertUtil.parseInt(map['weightUnit'])),
+      trustBLETime: ConvertUtil.parseBool(map['trustBLETime']),
+      showBLETimeTrustDialog: ConvertUtil.parseBool(map['showBLETimeTrustDialog']),
     );
 
     // update
@@ -162,6 +168,8 @@ class Settings extends ChangeNotifier {
     'bleInput': bleInput.serialize(),
     'weightInput': weightInput,
     'weightUnit': weightUnit.serialized,
+    'trustBLETime': trustBLETime,
+    'showBLETimeTrustDialog': showBLETimeTrustDialog,
   };
 
   /// Serialize the object to a restoreable string.
@@ -197,6 +205,8 @@ class Settings extends ChangeNotifier {
     _highestMedIndex = other._highestMedIndex;
     _weightInput = other._weightInput;
     _weightUnit = other._weightUnit;
+    _trustBLETime = other._trustBLETime;
+    _showBLETimeTrustDialog = other._showBLETimeTrustDialog;
     notifyListeners();
   }
 
@@ -441,7 +451,26 @@ class Settings extends ChangeNotifier {
     _weightUnit = value;
     notifyListeners();
   }
-  
+
+
+  bool _trustBLETime = true;
+  /// Whether to autofill the time the bluetooth device reports.
+  ///
+  /// This was introduced because the system time tends to be more accurate.
+  bool get trustBLETime => _trustBLETime;
+  set trustBLETime(bool value) {
+    _trustBLETime = value;
+    notifyListeners();
+  }
+
+  bool _showBLETimeTrustDialog = true;
+  /// Whether to warn the user when the time the bluetooth device reports is far
+  /// in the past and [trustBLETime] is true.
+  bool get showBLETimeTrustDialog => _showBLETimeTrustDialog;
+  set showBLETimeTrustDialog(bool value) {
+    _showBLETimeTrustDialog = value;
+    notifyListeners();
+  }
 // When adding fields notice the checklist at the top.
 }
 
app/lib/screens/settings_screen.dart
@@ -319,6 +319,14 @@ class SettingsPage extends StatelessWidget {
                     if (value != null) settings.weightUnit = value;
                   },
                 ),
+              SwitchListTile(
+                value: settings.trustBLETime,
+                title: Text(localizations.trustBLETime),
+                secondary: const Icon(Icons.lock_clock_outlined),
+                onChanged: (value) {
+                  settings.trustBLETime = value;
+                },
+              ),
             ],),
             TitledColumn(
               title: Text(localizations.data),
app/test/features/input/forms/add_entry_form_test.dart
@@ -7,11 +7,11 @@ import 'package:blood_pressure_app/features/input/forms/medicine_intake_form.dar
 import 'package:blood_pressure_app/features/input/forms/note_form.dart';
 import 'package:blood_pressure_app/features/input/forms/weight_form.dart';
 import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:health_data_store/health_data_store.dart';
 import 'package:intl/intl.dart';
@@ -79,7 +79,7 @@ void main() {
     testWidgets('show the BluetoothInput specified by setting', (tester) async {
       final settings = Settings(bleInput: BluetoothInputMode.disabled);
       await tester.pumpWidget(materialApp(AddEntryForm(
-        bluetoothCubit: _MockBluetoothCubit.new
+        bluetoothCubit: _MockBoringBluetoothCubit.new
       ), settings: settings));
 
       expect(find.byType(TabBar, skipOffstage: false), findsNothing);
@@ -442,9 +442,135 @@ void main() {
     final FullEntry entry = mockEntry(note: 'Test');
     expect(entry.asAddEntry.note?.note, 'Test');
   });
+
+  testWidgets("doesn't update time from ble if setting isn't set", (tester) async {
+    final key = GlobalKey<AddEntryFormState>();
+    final initialTime = DateTime.now();
+
+    await tester.pumpWidget(materialApp(AddEntryForm(key: key,
+      initialValue: (
+        timestamp: initialTime,
+        weight: null,
+        record: null,
+        note: null,
+        intake: null,
+      ),
+      mockBleInput: (callback) => ListTile(
+        onTap: () => callback(mockRecord(time: DateTime(2000))),
+        title: Text('mockBleInput'),
+      ),
+    ),
+      settings: Settings(
+        bleInput: BluetoothInputMode.disabled,
+        trustBLETime: false,
+      ),
+    ));
+    await tester.pumpAndSettle();
+
+    await tester.tap(find.text('mockBleInput'));
+    final returnedEntry = key.currentState!.save();
+    expect(returnedEntry!.timestamp.isAfter(DateTime(2000)), isTrue);
+    expect(returnedEntry.timestamp, initialTime);
+
+    // also check if the hint dialog isn't incorrectly displayed
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsNothing);
+  });
+
+  testWidgets('updates time from ble if setting is set', (tester) async {
+    final key = GlobalKey<AddEntryFormState>();
+    final initialTime = DateTime.now();
+
+    await tester.pumpWidget(materialApp(AddEntryForm(key: key,
+      initialValue: (
+        timestamp: initialTime,
+        weight: null,
+        record: null,
+        note: null,
+        intake: null,
+      ),
+      mockBleInput: (callback) => ListTile(
+        onTap: () => callback(mockRecord(time: DateTime(2000))),
+        title: Text('mockBleInput'),
+      ),
+    ),
+      settings: Settings(
+        bleInput: BluetoothInputMode.disabled,
+        trustBLETime: true,
+      ),
+    ));
+    await tester.pumpAndSettle();
+
+    await tester.tap(find.text('mockBleInput'));
+    final returnedEntry = key.currentState!.save();
+    expect(returnedEntry!.timestamp, equals(DateTime(2000)));
+  });
+
+  testWidgets('shows warning if time from ble is too old', (tester) async {
+    final localizations = await AppLocalizations.delegate.load(const Locale('en'));
+    await tester.pumpWidget(materialApp(AddEntryForm(
+      mockBleInput: (callback) => ListTile(
+        onTap: () => callback(mockRecord(time: DateTime(2000))),
+        title: Text('mockBleInput'),
+      ),
+    ),
+      settings: Settings(
+        bleInput: BluetoothInputMode.disabled,
+        trustBLETime: true,
+      ),
+    ));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AlertDialog), findsNothing);
+    await tester.tap(find.text('mockBleInput'));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsOneWidget);
+    expect(find.textContaining('The bluetooth device reported a time off by'), findsOneWidget);
+    expect(find.text(localizations.btnConfirm), findsOneWidget);
+
+    await tester.tap(find.text(localizations.btnConfirm));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsNothing);
+
+    // reopens the next time
+    await tester.tap(find.text('mockBleInput'));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsOneWidget);
+  });
+
+  testWidgets('allows disabling warning if time from ble is too old', (tester) async {
+    final localizations = await AppLocalizations.delegate.load(const Locale('en'));
+    await tester.pumpWidget(materialApp(AddEntryForm(
+      mockBleInput: (callback) => ListTile(
+        onTap: () => callback(mockRecord(time: DateTime(2000))),
+        title: Text('mockBleInput'),
+      ),
+    ),
+      settings: Settings(
+        bleInput: BluetoothInputMode.disabled,
+        trustBLETime: true,
+      ),
+    ));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AlertDialog), findsNothing);
+    await tester.tap(find.text('mockBleInput'));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsOneWidget);
+    expect(find.textContaining('The bluetooth device reported a time off by'), findsOneWidget);
+    expect(find.text(localizations.dontShowAgain), findsOneWidget);
+
+    await tester.tap(find.text(localizations.dontShowAgain));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsNothing);
+    await tester.tap(find.text('mockBleInput'));
+    await tester.pumpAndSettle();
+    expect(find.byType(AlertDialog), findsNothing);
+  });
 }
 
-class _MockBluetoothCubit extends Fake implements BluetoothCubit {
+/// A mock ble cubit that never does anything.
+class _MockBoringBluetoothCubit extends Fake implements BluetoothCubit {
   @override
   Future<void> close() async {}