From ad0b978b7681c18bf21fef5996de461495655f00 Mon Sep 17 00:00:00 2001 From: Codel1417 <13484789+Codel1417@users.noreply.github.com> Date: Thu, 22 Feb 2024 16:48:11 -0500 Subject: [PATCH] Replace nearby gear logic, add ota, fix scanning ui --- ios/Runner/Info.plist | 8 + lib/Backend/Bluetooth/BluetoothManager.dart | 40 +- .../Device/BaseDeviceDefinition.dart | 16 +- lib/Backend/DeviceRegistry.dart | 60 ++- lib/Backend/FirmwareUpdate.dart | 18 + .../NavigationObserver/CustomNavObserver.dart | 1 - lib/Backend/Sensors.dart | 33 +- lib/Frontend/Widgets/manage_devices.dart | 2 +- lib/Frontend/Widgets/scan_for_new_device.dart | 40 +- lib/Frontend/pages/Shell.dart | 380 ++++++++++-------- lib/Frontend/pages/ota_update.dart | 99 +++++ lib/main.dart | 14 +- pubspec.lock | 38 +- pubspec.yaml | 7 +- 14 files changed, 536 insertions(+), 220 deletions(-) create mode 100644 lib/Backend/FirmwareUpdate.dart create mode 100644 lib/Frontend/pages/ota_update.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 97e35aee..0595ccd8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,14 @@ Need Location permission NSMotionUsageDescription This application tracks your steps + NSBonjourServices + + _tailapp._tcp + + UIRequiresPersistentWiFi + + NSBluetoothAlwaysUsageDescription + {YOUR_DESCRIPTION} UIBackgroundModes processing diff --git a/lib/Backend/Bluetooth/BluetoothManager.dart b/lib/Backend/Bluetooth/BluetoothManager.dart index 40b2d41d..aac219dc 100644 --- a/lib/Backend/Bluetooth/BluetoothManager.dart +++ b/lib/Backend/Bluetooth/BluetoothManager.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:cross_platform/cross_platform.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_service/flutter_foreground_service.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; @@ -15,10 +16,13 @@ import 'package:tail_app/Frontend/Widgets/snack_bar_overlay.dart'; import '../Definitions/Device/BaseDeviceDefinition.dart'; import '../DeviceRegistry.dart'; +import '../FirmwareUpdate.dart'; import 'btMessage.dart'; part 'BluetoothManager.g.dart'; +Dio dio = Dio(); + @Riverpod(dependencies: [reactiveBLE, KnownDevices]) Stream scanForDevices(ScanForDevicesRef ref) { Flogger.d("Starting scan"); @@ -75,7 +79,7 @@ class KnownDevices extends _$KnownDevices { Future connect(DiscoveredDevice device) async { final ISentrySpan transaction = Sentry.startTransaction('connectToDevice()', 'task'); - BaseDeviceDefinition? deviceDefinition = DeviceRegistry.getByService(device.serviceUuids); + BaseDeviceDefinition? deviceDefinition = DeviceRegistry.getByName(device.name); if (deviceDefinition == null) { Flogger.w("Unknown device found: ${device.name}"); transaction.status = const SpanStatus.notFound(); @@ -113,10 +117,15 @@ class KnownDevices extends _$KnownDevices { Flogger.i("Received message from ${baseStoredDevice.name}: $value"); if (value.startsWith("VER")) { statefulDevice.fwVersion.value = value.substring(value.indexOf(" ")); + if (statefulDevice.fwInfo.value != null) { + if (statefulDevice.fwInfo.value?.version.split(" ")[1] != statefulDevice.fwVersion.value) { + statefulDevice.hasUpdate.value = true; + } + } } else if (value.startsWith("GLOWTIP")) { statefulDevice.glowTip.value = "TRUE" == value.substring(value.indexOf(" ")); } else if (value.contains("BUSY")) { - statefulDevice.deviceState.value = DeviceState.busy; + //statefulDevice.deviceState.value = DeviceState.busy; //TODO: add busy check to see if gear ready for next command } }); @@ -124,6 +133,11 @@ class KnownDevices extends _$KnownDevices { Flogger.d("Received Battery message from ${baseStoredDevice.name}: $event"); statefulDevice.battery.value = event.first.toDouble(); }); + statefulDevice.batteryChargeCharacteristicStreamSubscription = reactiveBLE.subscribeToCharacteristic(statefulDevice.batteryChargeCharacteristic).listen((List event) { + String value = const Utf8Decoder().convert(event); + Flogger.d("Received Battery Charge message from ${baseStoredDevice.name}: $value"); + statefulDevice.batteryCharging.value = value == "CHARGE ON"; + }); statefulDevice.keepAliveStreamSubscription = Stream.periodic(const Duration(seconds: 15)).listen((event) async { if (state.containsKey(device.id)) { statefulDevice.commandQueue.addCommand(BluetoothMessage("PING", statefulDevice, Priority.low)); @@ -132,6 +146,21 @@ class KnownDevices extends _$KnownDevices { throw Exception("Disconnected from device"); } }, cancelOnError: true); + if (deviceDefinition.fwURL != "") { + dio.get(statefulDevice.baseDeviceDefinition.fwURL, options: Options(responseType: ResponseType.json)).then( + (value) { + if (value.statusCode == 200) { + statefulDevice.fwInfo.value = FWInfo.fromJson(const JsonDecoder().convert(value.data.toString())); + if (statefulDevice.fwVersion.value != "") { + if (statefulDevice.fwInfo.value?.version.split(" ")[1] != statefulDevice.fwVersion.value) { + statefulDevice.hasUpdate.value = true; + } + } + } + }, + ).onError((error, stackTrace) => Flogger.e("Unable to get Firmware info for ${statefulDevice.baseDeviceDefinition.fwURL} :$error", stackTrace: stackTrace)); + } + statefulDevice.commandQueue.addCommand(BluetoothMessage("VER", statefulDevice, Priority.low)); } }); transaction.status = const SpanStatus.ok(); @@ -172,6 +201,13 @@ StreamSubscription btConnectStateHandler(BtConnectStateHa knownDevices[event.deviceId]?.keepAliveStreamSubscription = null; knownDevices[event.deviceId]?.battery.value = -1; knownDevices[event.deviceId]?.rssi.value = -1; + knownDevices[event.deviceId]?.hasUpdate.value = false; + knownDevices[event.deviceId]?.fwInfo.value = null; + knownDevices[event.deviceId]?.fwVersion.value = ""; + knownDevices[event.deviceId]?.batteryCharging.value = false; + knownDevices[event.deviceId]?.batteryChargeCharacteristicStreamSubscription?.cancel(); + knownDevices[event.deviceId]?.batteryChargeCharacteristicStreamSubscription = null; + ref.read(snackbarStreamProvider.notifier).add(SnackBar(content: Text("Disconnected from ${knownDevices[event.deviceId]?.baseStoredDevice.name}"))); //remove foreground service if no devices connected if (Platform.isAndroid && knownDevices.values.where((element) => element.deviceConnectionState.value == DeviceConnectionState.connected).isEmpty) { diff --git a/lib/Backend/Definitions/Device/BaseDeviceDefinition.dart b/lib/Backend/Definitions/Device/BaseDeviceDefinition.dart index c855b8ee..3d621b18 100644 --- a/lib/Backend/Definitions/Device/BaseDeviceDefinition.dart +++ b/lib/Backend/Definitions/Device/BaseDeviceDefinition.dart @@ -6,6 +6,7 @@ import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:tail_app/Backend/Bluetooth/BluetoothManager.dart'; +import 'package:tail_app/Backend/FirmwareUpdate.dart'; import '../../../Frontend/intnDefs.dart'; @@ -49,15 +50,14 @@ enum DeviceState { standby, runAction, busy } class BaseDeviceDefinition { final String uuid; - final String model; final String btName; final Uuid bleDeviceService; final Uuid bleRxCharacteristic; final Uuid bleTxCharacteristic; - final Icon icon; final DeviceType deviceType; + final String fwURL; - const BaseDeviceDefinition(this.uuid, this.model, this.btName, this.bleDeviceService, this.bleRxCharacteristic, this.bleTxCharacteristic, this.icon, this.deviceType); + const BaseDeviceDefinition(this.uuid, this.btName, this.bleDeviceService, this.bleRxCharacteristic, this.bleTxCharacteristic, this.deviceType, this.fwURL); @override String toString() { @@ -72,8 +72,10 @@ class BaseStatefulDevice { late QualifiedCharacteristic rxCharacteristic; late QualifiedCharacteristic txCharacteristic; late QualifiedCharacteristic batteryCharacteristic; - late QualifiedCharacteristic otaCharacteristic; + late QualifiedCharacteristic batteryChargeCharacteristic; + ValueNotifier battery = ValueNotifier(-1); + ValueNotifier batteryCharging = ValueNotifier(false); ValueNotifier fwVersion = ValueNotifier(""); ValueNotifier glowTip = ValueNotifier(false); StreamSubscription? connectionStateStreamSubscription; @@ -84,6 +86,8 @@ class BaseStatefulDevice { Stream>? get rxCharacteristicStream => _rxCharacteristicStream; ValueNotifier deviceConnectionState = ValueNotifier(DeviceConnectionState.disconnected); ValueNotifier rssi = ValueNotifier(-1); + ValueNotifier fwInfo = ValueNotifier(null); + ValueNotifier hasUpdate = ValueNotifier(false); set rxCharacteristicStream(Stream>? value) { _rxCharacteristicStream = value?.asBroadcastStream(); @@ -92,13 +96,15 @@ class BaseStatefulDevice { Ref? ref; late CommandQueue commandQueue; StreamSubscription>? batteryCharacteristicStreamSubscription; + StreamSubscription>? batteryChargeCharacteristicStreamSubscription; BaseStatefulDevice(this.baseDeviceDefinition, this.baseStoredDevice, this.ref) { rxCharacteristic = QualifiedCharacteristic(characteristicId: baseDeviceDefinition.bleRxCharacteristic, serviceId: baseDeviceDefinition.bleDeviceService, deviceId: baseStoredDevice.btMACAddress); txCharacteristic = QualifiedCharacteristic(characteristicId: baseDeviceDefinition.bleTxCharacteristic, serviceId: baseDeviceDefinition.bleDeviceService, deviceId: baseStoredDevice.btMACAddress); batteryCharacteristic = QualifiedCharacteristic(serviceId: Uuid.parse("0000180f-0000-1000-8000-00805f9b34fb"), characteristicId: Uuid.parse("00002a19-0000-1000-8000-00805f9b34fb"), deviceId: baseStoredDevice.btMACAddress); + batteryChargeCharacteristic = QualifiedCharacteristic(serviceId: Uuid.parse("0000180f-0000-1000-8000-00805f9b34fb"), characteristicId: Uuid.parse("5073792e-4fc0-45a0-b0a5-78b6c1756c91"), deviceId: baseStoredDevice.btMACAddress); + commandQueue = CommandQueue(ref, this); - otaCharacteristic = QualifiedCharacteristic(characteristicId: Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), serviceId: Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), deviceId: baseStoredDevice.btMACAddress); } @override diff --git a/lib/Backend/DeviceRegistry.dart b/lib/Backend/DeviceRegistry.dart index f4e32625..8152244c 100644 --- a/lib/Backend/DeviceRegistry.dart +++ b/lib/Backend/DeviceRegistry.dart @@ -11,16 +11,68 @@ part 'DeviceRegistry.g.dart'; @immutable class DeviceRegistry { static Set allDevices = { - BaseDeviceDefinition("798e1528-2832-4a87-93d7-4d1b25a2f418", "MiTail", "MiTail", Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), Uuid.parse("c6612b64-0087-4974-939e-68968ef294b0"), const Icon(Icons.bluetooth), DeviceType.tail), - BaseDeviceDefinition("927dee04-ddd4-4582-8e42-69dc9fbfae66", "EG2", "EG2", Uuid.parse("927dee04-ddd4-4582-8e42-69dc9fbfae66"), Uuid.parse("0b646a19-371e-4327-b169-9632d56c0e84"), Uuid.parse("05e026d8-b395-4416-9f8a-c00d6c3781b9"), const Icon(Icons.bluetooth), DeviceType.ears) + BaseDeviceDefinition( + "798e1528-2832-4a87-93d7-4d1b25a2f418", + "MiTail", + Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), + Uuid.parse("c6612b64-0087-4974-939e-68968ef294b0"), + Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), + DeviceType.tail, + "https://thetailcompany.com/fw/mitail", + ), + BaseDeviceDefinition( + "9c5f3692-1c6e-4d46-b607-4f6f4a6e28ee", + "(!)Tail1", + Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), + Uuid.parse("c6612b64-0087-4974-939e-68968ef294b0"), + Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), + DeviceType.tail, + "", + ), + BaseDeviceDefinition( + "5fb21175-fef4-448a-a38b-c472d935abab", + "minitail", + Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), + Uuid.parse("c6612b64-0087-4974-939e-68968ef294b0"), + Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), + DeviceType.tail, + "https://thetailcompany.com/fw/mini", + ), + BaseDeviceDefinition( + "e790f509-f95b-4eb4-b649-5b43ee1eee9c", + "flutter", + Uuid.parse("3af2108b-d066-42da-a7d4-55648fa0a9b6"), + Uuid.parse("c6612b64-0087-4974-939e-68968ef294b0"), + Uuid.parse("5bfd6484-ddee-4723-bfe6-b653372bbfd6"), + DeviceType.wings, + "https://thetailcompany.com/fw/flutter", + ), + BaseDeviceDefinition( + "927dee04-ddd4-4582-8e42-69dc9fbfae66", + "EG2", + Uuid.parse("927dee04-ddd4-4582-8e42-69dc9fbfae66"), + Uuid.parse("0b646a19-371e-4327-b169-9632d56c0e84"), + Uuid.parse("05e026d8-b395-4416-9f8a-c00d6c3781b9"), + DeviceType.ears, + "https://thetailcompany.com/fw/eg", + ), + BaseDeviceDefinition( + "ba2f2b00-8f65-4cc3-afad-58ba1fccd62d", + "EarGear", + Uuid.parse("927dee04-ddd4-4582-8e42-69dc9fbfae66"), + Uuid.parse("0b646a19-371e-4327-b169-9632d56c0e84"), + Uuid.parse("05e026d8-b395-4416-9f8a-c00d6c3781b9"), + DeviceType.ears, + "", + ), }; static BaseDeviceDefinition getByUUID(String uuid) { return allDevices.firstWhere((BaseDeviceDefinition element) => element.uuid == uuid); } - static BaseDeviceDefinition getByName(String id) { - return allDevices.firstWhere((BaseDeviceDefinition element) => element.btName == id); + static BaseDeviceDefinition? getByName(String id) { + return allDevices.firstWhere((BaseDeviceDefinition element) => element.btName.toLowerCase() == id.toLowerCase()); } static bool hasByName(String id) { diff --git a/lib/Backend/FirmwareUpdate.dart b/lib/Backend/FirmwareUpdate.dart new file mode 100644 index 00000000..0b7171b9 --- /dev/null +++ b/lib/Backend/FirmwareUpdate.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'FirmwareUpdate.g.dart'; + +@JsonSerializable() +class FWInfo { + String version; + String md5sum; + String url; + String changelog; + String glash; + + FWInfo(this.version, this.md5sum, this.url, this.changelog, this.glash); + + factory FWInfo.fromJson(Map json) => _$FWInfoFromJson(json); + + Map toJson() => _$FWInfoToJson(this); +} diff --git a/lib/Backend/NavigationObserver/CustomNavObserver.dart b/lib/Backend/NavigationObserver/CustomNavObserver.dart index e7913a48..01031dcc 100644 --- a/lib/Backend/NavigationObserver/CustomNavObserver.dart +++ b/lib/Backend/NavigationObserver/CustomNavObserver.dart @@ -16,7 +16,6 @@ class CustomNavObserver extends NavigatorObserver { String? name = route.settings.name; String refferalName = previousRoute?.settings.name ?? ""; if (name != null) { - plausible.screenWidth = MediaQuery.of(route.navigator!.context).size.width.toString(); plausible.event(page: route.settings.name.toString(), props: {"Number Of Devices": SentryHive.box('devices').length.toString()}, referrer: refferalName); } } diff --git a/lib/Backend/Sensors.dart b/lib/Backend/Sensors.dart index 182a2c9c..b4fb3229 100644 --- a/lib/Backend/Sensors.dart +++ b/lib/Backend/Sensors.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart'; -import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_nearby_connections/flutter_nearby_connections.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:logging_flutter/logging_flutter.dart'; @@ -19,13 +19,13 @@ import '../Frontend/intnDefs.dart'; import 'Bluetooth/BluetoothManager.dart'; import 'Definitions/Action/BaseAction.dart'; import 'Definitions/Device/BaseDeviceDefinition.dart'; -import 'DeviceRegistry.dart'; import 'moveLists.dart'; part 'Sensors.g.dart'; //TODO: wrap EarGear Mic and Tilt to Sensors, send enable/disable commands with toggle //TODO: error callback to disable the sensor from the trigger definition, such as when permission is denied +//TODO: Call disable method when last device reconnects, call enable method when first device connects @HiveType(typeId: 2) class Trigger { @HiveField(1) @@ -70,7 +70,7 @@ class Trigger { } @HiveField(3) - List actions = []; //TODO: Store action as a uuid, and find on demand + List actions = []; Trigger(this.triggerDef) { // called by hive when loading object @@ -157,10 +157,10 @@ class WalkingTriggerDefinition extends TriggerDefinition { pedestrianStatusStream = Pedometer.pedestrianStatusStream.listen( (PedestrianStatus event) { Flogger.i("PedestrianStatus:: ${event.status}"); - if (event.status == "Walking") { + if (event.status == "walking") { TriggerAction? action = actions.firstWhere((element) => actionTypes.firstWhere((element) => element.name == "Walking").uuid == element.uuid); sendCommands(deviceType, action.action, ref); - } else if (event.status == "Stopped") { + } else if (event.status == "stopped") { TriggerAction? action = actions.firstWhere((element) => actionTypes.firstWhere((element) => element.name == "Stopped").uuid == element.uuid); sendCommands(deviceType, action.action, ref); } @@ -278,7 +278,8 @@ class ShakeTriggerDefinition extends TriggerDefinition { } class TailProximityTriggerDefinition extends TriggerDefinition { - StreamSubscription? btStream; + StreamSubscription? subscription; + NearbyService? nearbyService; TailProximityTriggerDefinition(super.ref) { super.name = triggerProximityTitle(); @@ -290,14 +291,26 @@ class TailProximityTriggerDefinition extends TriggerDefinition { @override Future onDisable() async { - btStream?.cancel(); - btStream = null; + subscription?.cancel(); + subscription = null; + await nearbyService?.stopAdvertisingPeer(); + await nearbyService?.stopBrowsingForPeers(); } @override Future onEnable(Set actions, Set deviceType) async { - btStream = ref.read(reactiveBLEProvider).scanForDevices(withServices: DeviceRegistry.getAllIds()).where((event) => !ref.read(knownDevicesProvider).keys.contains(event.id)).listen((DiscoveredDevice device) { - Flogger.d("TailProximityTriggerDefinition:: $device"); + nearbyService = NearbyService(); + await nearbyService?.init( + serviceType: "tailapp", + strategy: Strategy.P2P_POINT_TO_POINT, + callback: (isRunning) async { + if (isRunning) { + await nearbyService?.startAdvertisingPeer(); + await nearbyService?.startBrowsingForPeers(); + } + }); + subscription = nearbyService?.stateChangedSubscription(callback: (devicesList) { + Flogger.d("TailProximityTriggerDefinition::"); TriggerAction? action = actions.firstWhere((element) => actionTypes.firstWhere((element) => element.name == "Nearby Gear").uuid == element.uuid); sendCommands(deviceType, action.action, ref); }); diff --git a/lib/Frontend/Widgets/manage_devices.dart b/lib/Frontend/Widgets/manage_devices.dart index 1b0dbe99..84466102 100644 --- a/lib/Frontend/Widgets/manage_devices.dart +++ b/lib/Frontend/Widgets/manage_devices.dart @@ -8,7 +8,7 @@ class ManageDevices extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - children: const [ + children: [ ManageKnownDevices(), ScanForNewDevice(), ], diff --git a/lib/Frontend/Widgets/scan_for_new_device.dart b/lib/Frontend/Widgets/scan_for_new_device.dart index 3fb07890..034c3bc7 100644 --- a/lib/Frontend/Widgets/scan_for_new_device.dart +++ b/lib/Frontend/Widgets/scan_for_new_device.dart @@ -10,7 +10,7 @@ import '../../Backend/Bluetooth/BluetoothManager.dart'; import '../intnDefs.dart'; class ScanForNewDevice extends ConsumerStatefulWidget { - const ScanForNewDevice({super.key}); + ScanForNewDevice({super.key}); @override ConsumerState createState() => _ScanForNewDevice(); @@ -21,6 +21,7 @@ class _ScanForNewDevice extends ConsumerState { @override void initState() { + devices = {}; super.initState(); } @@ -54,23 +55,28 @@ class _ScanForNewDevice extends ConsumerState { title: Text(scanDevicesAutoConnectTitle()), ), Wrap( - children: devices.values - .map( - (e) => ListTile( - title: Text(getNameFromBTName(e.name)), - trailing: Text(e.id), - onTap: () { - ref.watch(knownDevicesProvider.notifier).connect(e); - setState( - () { - devices.remove(e.id); + children: [ + ListView( + shrinkWrap: true, + children: devices.values + .map( + (e) => ListTile( + title: Text(getNameFromBTName(e.name)), + trailing: Text(e.id), + onTap: () { + ref.watch(knownDevicesProvider.notifier).connect(e); + setState( + () { + devices.remove(e.id); + }, + ); + Navigator.pop(context); }, - ); - //Navigator.pop(context); - }, - ), - ) - .toList(), + ), + ) + .toList(), + ), + ], ), Padding( padding: const EdgeInsets.only(top: 20), diff --git a/lib/Frontend/pages/Shell.dart b/lib/Frontend/pages/Shell.dart index 8ded3f51..90eec177 100644 --- a/lib/Frontend/pages/Shell.dart +++ b/lib/Frontend/pages/Shell.dart @@ -4,7 +4,6 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:multi_value_listenable_builder/multi_value_listenable_builder.dart'; import 'package:tail_app/Backend/Bluetooth/BluetoothManager.dart'; import 'package:tail_app/Backend/Definitions/Device/BaseDeviceDefinition.dart'; import 'package:tail_app/Frontend/Widgets/scan_for_new_device.dart'; @@ -12,6 +11,7 @@ import 'package:tail_app/Frontend/Widgets/snack_bar_overlay.dart'; import 'package:upgrader/upgrader.dart'; import '../../Backend/AutoMove.dart'; +import '../../main.dart'; import '../intnDefs.dart'; /// Flutter code sample for [NavigationDrawer]. @@ -163,24 +163,30 @@ class _NavigationDrawerExampleState extends ConsumerState 1 ? 200 : 100, + width: ref.watch(knownDevicesProvider).values.length > 1 ? 100 : 200, child: Center( child: Text( - "Scan For New Devices", + "Scan For New Gear", textAlign: TextAlign.center, ), ), ), ), onTap: () { - showDialog( + plausible.event(page: "Scan For New Gear"); + showModalBottomSheet( context: context, - useRootNavigator: false, + showDragHandle: true, + isScrollControlled: true, + enableDrag: true, + isDismissible: true, builder: (BuildContext context) { - return SimpleDialog( - title: Text("Scan For New Devices"), + return Wrap( children: [ - const ScanForNewDevice(), + ListTile( + title: Text("Scan For New Gear"), + ), + ScanForNewDevice(), Center( child: TextButton( onPressed: () { @@ -207,188 +213,214 @@ class _NavigationDrawerExampleState extends ConsumerState( - multiSelectionEnabled: true, - selected: e.baseStoredDevice.selectedAutoCategories.toSet(), - onSelectionChanged: (Set value) { - setState(() { - e.baseStoredDevice.selectedAutoCategories = value.toList(); - }); - ref.read(knownDevicesProvider.notifier).store(); - ChangeAutoMove(e); - }, - segments: AutoActionCategory.values.map>( - (AutoActionCategory value) { - return ButtonSegment( - value: value, - label: Text(value.friendly), - ); - }, - ).toList(), - ), - ), - ListTile( - title: Text(manageDevicesAutoMovePauseTitle()), - subtitle: RangeSlider( - labels: RangeLabels(manageDevicesAutoMovePauseSliderLabel(e.baseStoredDevice.autoMoveMinPause.round()), manageDevicesAutoMovePauseSliderLabel(e.baseStoredDevice.autoMoveMaxPause.round())), - min: 15, - max: 240, - values: RangeValues(e.baseStoredDevice.autoMoveMinPause, e.baseStoredDevice.autoMoveMaxPause), - onChanged: (RangeValues value) { - setState(() { - e.baseStoredDevice.autoMoveMinPause = value.start; - e.baseStoredDevice.autoMoveMaxPause = value.end; - }); - ref.read(knownDevicesProvider.notifier).store(); - }, - onChangeEnd: (values) { - ChangeAutoMove(e); - }, - )), - ListTile( - title: Text(manageDevicesAutoMoveNoPhoneTitle()), - subtitle: Slider( - value: e.baseStoredDevice.noPhoneDelayTime, - min: 1, - max: 60, - onChanged: (double value) { - setState(() { - e.baseStoredDevice.noPhoneDelayTime = value; - }); - ref.read(knownDevicesProvider.notifier).store(); - }, - label: manageDevicesAutoMoveNoPhoneSliderLabel(e.baseStoredDevice.noPhoneDelayTime.round()), - ), - ), - ButtonBar( - alignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - setState(() { - e.connectionStateStreamSubscription = null; - }); - }, - child: Text(manageDevicesDisconnect()), + ), ), - TextButton( - onPressed: () { - setState(() { - e.connectionStateStreamSubscription = null; - }); - ref.watch(knownDevicesProvider.notifier).remove(e.baseStoredDevice.btMACAddress); - }, - child: Text(manageDevicesForget()), + ListTile( + title: Text(manageDevicesAutoMoveGroupsTitle()), + subtitle: SegmentedButton( + multiSelectionEnabled: true, + selected: e.baseStoredDevice.selectedAutoCategories.toSet(), + onSelectionChanged: (Set value) { + setState(() { + e.baseStoredDevice.selectedAutoCategories = value.toList(); + }); + ref.read(knownDevicesProvider.notifier).store(); + ChangeAutoMove(e); + }, + segments: AutoActionCategory.values.map>( + (AutoActionCategory value) { + return ButtonSegment( + value: value, + label: Text(value.friendly), + ); + }, + ).toList(), + ), + ), + ListTile( + title: Text(manageDevicesAutoMovePauseTitle()), + subtitle: RangeSlider( + labels: RangeLabels(manageDevicesAutoMovePauseSliderLabel(e.baseStoredDevice.autoMoveMinPause.round()), manageDevicesAutoMovePauseSliderLabel(e.baseStoredDevice.autoMoveMaxPause.round())), + min: 15, + max: 240, + values: RangeValues(e.baseStoredDevice.autoMoveMinPause, e.baseStoredDevice.autoMoveMaxPause), + onChanged: (RangeValues value) { + setState(() { + e.baseStoredDevice.autoMoveMinPause = value.start; + e.baseStoredDevice.autoMoveMaxPause = value.end; + }); + ref.read(knownDevicesProvider.notifier).store(); + }, + onChangeEnd: (values) { + ChangeAutoMove(e); + }, + ), + ), + ListTile( + title: Text(manageDevicesAutoMoveNoPhoneTitle()), + subtitle: Slider( + value: e.baseStoredDevice.noPhoneDelayTime, + min: 1, + max: 60, + onChanged: (double value) { + setState(() { + e.baseStoredDevice.noPhoneDelayTime = value; + }); + ref.read(knownDevicesProvider.notifier).store(); + }, + label: manageDevicesAutoMoveNoPhoneSliderLabel(e.baseStoredDevice.noPhoneDelayTime.round()), + ), + ), + ButtonBar( + alignment: MainAxisAlignment.end, + children: [ + value == DeviceConnectionState.connected + ? TextButton( + onPressed: () { + setState(() { + e.connectionStateStreamSubscription?.cancel(); + }); + }, + child: Text(manageDevicesDisconnect()), + ) + : Container(), + TextButton( + onPressed: () { + setState(() { + e.connectionStateStreamSubscription = null; + }); + ref.watch(knownDevicesProvider.notifier).remove(e.baseStoredDevice.btMACAddress); + }, + child: Text(manageDevicesForget()), + ) + ], ) ], - ) - ], + ); + }, ); }, ); - }); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: 50, - width: 100, - child: Stack( - children: [ - Text(e.baseStoredDevice.name), - Align( - alignment: Alignment.bottomLeft, - child: MultiValueListenableBuilder( - builder: (BuildContext context, List values, Widget? child) { - if (e.deviceConnectionState.value == DeviceConnectionState.connected) { - return getBattery(e.battery.value); - } else { - return Text(e.deviceConnectionState.value.name); - } - }, - valueListenables: [e.battery, e.deviceConnectionState], + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 50, + width: 100, + child: Stack( + children: [ + Text(e.baseStoredDevice.name), + Align( + alignment: Alignment.bottomCenter, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: value == DeviceConnectionState.connected + ? [ + ValueListenableBuilder( + valueListenable: e.battery, + builder: (BuildContext context, value, Widget? child) { + return getBattery(e.battery.value); + }, + ), + ValueListenableBuilder( + valueListenable: e.batteryCharging, + builder: (BuildContext context, value, Widget? child) { + if (e.deviceConnectionState.value == DeviceConnectionState.connected && e.batteryCharging.value) { + return const Icon(Icons.power); + } else { + return Container(); + } + }, + ), + ValueListenableBuilder( + valueListenable: e.rssi, + builder: (BuildContext context, value, Widget? child) { + return getSignal(e.rssi.value); + }, + ), + ] + : [const Icon(Icons.bluetooth_disabled)], + ), + ) + ], ), ), - Align( - alignment: Alignment.bottomRight, - child: MultiValueListenableBuilder( - builder: (BuildContext context, List values, Widget? child) { - if (e.deviceConnectionState.value == DeviceConnectionState.connected) { - return getSignal(e.rssi.value); - } - return Container(); - }, - valueListenables: [e.rssi, e.deviceConnectionState], - ), - ) - ], + ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/Frontend/pages/ota_update.dart b/lib/Frontend/pages/ota_update.dart new file mode 100644 index 00000000..bac14ac8 --- /dev/null +++ b/lib/Frontend/pages/ota_update.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tail_app/Backend/Bluetooth/BluetoothManager.dart'; +import 'package:tail_app/Backend/Definitions/Device/BaseDeviceDefinition.dart'; + +import '../../Backend/FirmwareUpdate.dart'; + +class OtaUpdate extends ConsumerStatefulWidget { + OtaUpdate({super.key, required this.device}); + + String device; + + @override + _OtaUpdateState createState() => _OtaUpdateState(); +} + +class _OtaUpdateState extends ConsumerState { + double downloadProgress = 0; + double uploadProgress = 0; + FWInfo? updateURL; + Dio dio = Dio(); + List? firmwareFile; + + @override + Widget build(BuildContext context) { + updateURL ??= ref.read(knownDevicesProvider)[widget.device]?.fwInfo.value; + downloadFirmware(); + if (downloadProgress == 1) { + uploadFirmware(); + } + return Scaffold( + appBar: AppBar(title: Text("Update in progress")), + body: Center( + child: Column( + children: [ + Text("Updating gear"), + ListTile( + title: Text("Downloading"), + leading: const Icon(Icons.download), + subtitle: LinearProgressIndicator(value: downloadProgress), + ), + ListTile( + title: Text("Uploading"), + leading: const Icon(Icons.upload), + subtitle: LinearProgressIndicator(value: uploadProgress), + ) + ], + ), + ), + ); + } + + Future downloadFirmware() async { + final Response> rs = await Dio().get>(updateURL!.url, options: Options(responseType: ResponseType.bytes), onReceiveProgress: (current, total) { + setState(() { + downloadProgress = current / total; + }); + }); + if (rs.statusCode == 200) { + downloadProgress = 1; + Digest digest = md5.convert(rs.data!); + if (digest.toString() == updateURL!.md5sum) { + firmwareFile = rs.data; + } + } + } + + Future uploadFirmware() async { + BaseStatefulDevice? baseStatefulDevice = ref.read(knownDevicesProvider)[widget.device]; + if (firmwareFile != null && baseStatefulDevice != null) { + int mtu = await ref.read(reactiveBLEProvider).requestMtu(deviceId: baseStatefulDevice.baseStoredDevice.btMACAddress, mtu: 512) - 10; + int total = firmwareFile!.length; + int current = 0; + while (uploadProgress < 1) { + baseStatefulDevice.deviceState.value = DeviceState.busy; // hold the command queue + int nextEnd = current + mtu; + if (nextEnd > total) { + nextEnd = total; + } + List chunk = firmwareFile!.sublist(current, nextEnd); + if (current == 0) { + List beginOTA = const Utf8Encoder().convert("OTA "); + beginOTA.addAll(chunk); + chunk = beginOTA; + } + await ref.read(reactiveBLEProvider).writeCharacteristicWithResponse(baseStatefulDevice.txCharacteristic, value: chunk); + current = current + chunk.length; + setState(() { + uploadProgress = current / total; + }); + } + baseStatefulDevice.deviceState.value = DeviceState.standby; // hold the command queue + } + } +} diff --git a/lib/main.dart b/lib/main.dart index b891fc74..d621a559 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:dio/dio.dart'; import 'package:feedback_sentry/feedback_sentry.dart'; import 'package:fk_user_agent/fk_user_agent.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; @@ -13,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging_flutter/logging_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:plausible_analytics/plausible_analytics.dart'; +import 'package:sentry_dio/sentry_dio.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_hive/sentry_hive.dart'; import 'package:sentry_logging/sentry_logging.dart'; @@ -92,8 +94,8 @@ Future main() async { (options) { options.dsn = 'https://1c6815c83f0644db8d569f0ba454f035@glitchtip.codel1417.xyz/2'; options.addIntegration(LoggingIntegration()); - options.attachScreenshot = true; - options.attachViewHierarchy = true; + options.attachScreenshot = true; //not supported on GlitchTip + options.attachViewHierarchy = true; //not supported on GlitchTip options.tracesSampleRate = 1.0; options.profilesSampleRate = 1.0; options.attachThreads = true; @@ -124,6 +126,14 @@ Future main() async { ); } +void initDio() { + final dio = Dio(); + + /// This *must* be the last initialization step of the Dio setup, otherwise + /// your configuration of Dio might overwrite the Sentry configuration. + dio.addSentry(); +} + class TailApp extends ConsumerWidget { const TailApp({super.key}); diff --git a/pubspec.lock b/pubspec.lock index 1ba7acc6..8e9c8c7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: built_value - sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e url: "https://pub.dev" source: hosted - version: "8.9.0" + version: "8.9.1" characters: dependency: transitive description: @@ -218,7 +218,7 @@ packages: source: hosted version: "3.0.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -281,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" + url: "https://pub.dev" + source: hosted + version: "5.4.1" double_back_to_close_app: dependency: "direct main" description: @@ -427,6 +435,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.10" + flutter_nearby_connections: + dependency: "direct main" + description: + name: flutter_nearby_connections + sha256: f2003fde3df5d99ce15693b1603de168f66a2e7c50b84786191236cc32e3afdc + url: "https://pub.dev" + source: hosted + version: "1.1.2" flutter_reactive_ble: dependency: "direct main" description: @@ -629,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" + json_serializable: + dependency: "direct main" + description: + name: json_serializable + sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + url: "https://pub.dev" + source: hosted + version: "6.7.1" lints: dependency: transitive description: @@ -1053,6 +1077,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.7.1" + sentry_dio: + dependency: "direct main" + description: + name: sentry_dio + sha256: "3812f44fb6657cb04806cbfbd1dd03d00fb6aceb06bf5dd236ecff56b754a1cf" + url: "https://pub.dev" + source: hosted + version: "7.16.1" sentry_flutter: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 671491e3..d5e52aca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: sentry_flutter: ^7.16.1 sentry_logging: ^7.16.1 sentry_hive: ^7.16.1 + sentry_dio: ^7.16.1 vector_math: ^2.1.4 collection: ^1.18.0 flutter_foreground_service: ^0.4.1 @@ -47,6 +48,10 @@ dependencies: uuid: ^4.3.3 plausible_analytics: ^0.3.0 fk_user_agent: ^2.1.0 + flutter_nearby_connections: ^1.1.2 + dio: ^5.4.1 + json_serializable: ^6.7.1 + crypto: ^3.0.3 dev_dependencies: build_runner: flutter_test: @@ -57,7 +62,7 @@ dev_dependencies: riverpod_generator: flutter_native_splash: ^2.3.10 riverpod_annotation: ^2.3.4 - sentry_dart_plugin: ^1.7.0 + sentry_dart_plugin: ^1.7.1 intl_translation: hive_generator: ^2.0.1 flutter: