diff --git a/lib/Backend/firmware_update.dart b/lib/Backend/firmware_update.dart index a72345ad..35190f14 100644 --- a/lib/Backend/firmware_update.dart +++ b/lib/Backend/firmware_update.dart @@ -1,11 +1,20 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:tail_app/Backend/plausible_dio.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../Frontend/utils.dart'; +import '../constants.dart'; +import 'Bluetooth/bluetooth_manager_plus.dart'; import 'Definitions/Device/device_definition.dart'; +import 'logging_wrappers.dart'; import 'version.dart'; part 'firmware_update.freezed.dart'; @@ -75,3 +84,239 @@ Future hasOtaUpdate(HasOtaUpdateRef ref, BaseStatefulDevice baseStatefulDe } return false; } + +enum OtaState { + standby, + download, + upload, + error, + manual, + completed, + lowBattery, + rebooting, +} + +class OtaUpdater { + Function(double)? onProgress; + Function(OtaState)? onStateChanged; + BaseStatefulDevice baseStatefulDevice; + OtaState _otaState = OtaState.standby; + + OtaState get otaState => _otaState; + double _downloadProgress = 0; + + double get downloadProgress => _downloadProgress; + + set downloadProgress(double value) { + _downloadProgress = value; + _progress = downloadProgress < 1 ? downloadProgress : uploadProgress; + } + + double _uploadProgress = 0; + + double get uploadProgress => _uploadProgress; + + set uploadProgress(double value) { + _uploadProgress = value; + _progress = downloadProgress < 1 ? downloadProgress : uploadProgress; + } + + double _progress = 0; + + double get progress => _progress; + + FWInfo? firmwareInfo; + List? firmwareFile; + String? downloadedMD5; + bool _wakelockEnabledBeforehand = false; + int current = 0; + Duration timeRemainingMS = Duration.zero; + Timer? _timer; + final Logger _otaLogger = Logger('otaLogger'); + + void setManualOtaFile(List? bytes) { + if (bytes == null) { + return; + } + firmwareFile = bytes; + Digest digest = md5.convert(firmwareFile!); + downloadedMD5 = digest.toString(); + otaState = OtaState.manual; + downloadProgress = 1; + } + + void _updateProgress() { + if (onProgress != null) { + onProgress!((downloadProgress + uploadProgress) / 2); + } + } + + set otaState(OtaState value) { + _otaState = value; + if (onStateChanged != null) { + onStateChanged!(value); + } + } + + Future beginUpdate() async { + if (baseStatefulDevice.batteryLevel.value < 50) { + otaState = OtaState.lowBattery; + return; + } + WakelockPlus.enable(); + if (firmwareFile == null) { + await downloadFirmware(); + } + if (otaState != OtaState.error) { + await uploadFirmware(); + } + } + + Future downloadFirmware() async { + if (firmwareInfo == null) { + return; + } + otaState = OtaState.download; + downloadProgress = 0; + _updateProgress(); + final transaction = Sentry.startTransaction('OTA Download', 'http')..setTag("GearType", baseStatefulDevice.baseDeviceDefinition.btName); + try { + final Response> rs = await (await initDio()).get>( + firmwareInfo!.url, + options: Options(responseType: ResponseType.bytes), + onReceiveProgress: (current, total) { + downloadProgress = current / total; + _updateProgress(); + }, + ); + if (rs.statusCode == 200) { + downloadProgress = 1; + Digest digest = md5.convert(rs.data!); + downloadedMD5 = digest.toString(); + if (digest.toString() == firmwareInfo!.md5sum) { + firmwareFile = rs.data; + } else { + transaction.status = const SpanStatus.dataLoss(); + otaState = OtaState.error; + } + } + } catch (e) { + transaction + ..throwable = e + ..status = const SpanStatus.internalError(); + otaState = OtaState.error; + } + transaction.finish(); + } + + Future verListener() async { + Version version = baseStatefulDevice.fwVersion.value; + FWInfo? fwInfo = firmwareInfo; + if (fwInfo != null && version.compareTo(const Version()) > 0 && otaState == OtaState.rebooting) { + bool updated = version.compareTo(getVersionSemVer(fwInfo.version)) >= 0; + otaState = updated ? OtaState.completed : OtaState.error; + } + } + + void fwInfoListener() { + firmwareInfo = baseStatefulDevice.fwInfo.value; + } + + Future uploadFirmware() async { + otaState = OtaState.upload; + uploadProgress = 0; + Stopwatch timeToUpdate = Stopwatch(); + final transaction = Sentry.startTransaction('updateGear()', 'task'); + try { + if (firmwareFile != null) { + transaction.setTag("GearType", baseStatefulDevice.baseDeviceDefinition.btName); + baseStatefulDevice.gearReturnedError.value = false; + int mtu = baseStatefulDevice.mtu.value - 10; + int total = firmwareFile!.length; + current = 0; + baseStatefulDevice.gearReturnedError.value = false; + + _otaLogger.info("Holding the command queue"); + timeToUpdate.start(); + _otaLogger.info("Send OTA begin message"); + List beginOTA = List.from(const Utf8Encoder().convert("OTA ${firmwareFile!.length} $downloadedMD5")); + await sendMessage(baseStatefulDevice, beginOTA); + + while (uploadProgress < 1 && otaState != OtaState.error) { + baseStatefulDevice.deviceState.value = DeviceState.busy; // hold the command queue + if (baseStatefulDevice.gearReturnedError.value) { + transaction.status = const SpanStatus.unavailable(); + otaState = OtaState.error; + + break; + } + + List chunk = firmwareFile!.skip(current).take(mtu).toList(); + if (chunk.isNotEmpty) { + try { + //_otaLogger.info("Updating $uploadProgress"); + if (current > 0) { + timeRemainingMS = Duration(milliseconds: ((timeToUpdate.elapsedMilliseconds / current) * (total - current)).toInt()); + } + + await sendMessage(baseStatefulDevice, chunk, withoutResponse: true); + } catch (e, s) { + _otaLogger.severe("Exception during ota upload:$e", e, s); + if ((current + chunk.length) / total < 0.99) { + transaction + ..status = const SpanStatus.unknownError() + ..throwable = e; + otaState = OtaState.error; + return; + } + } + current = current + chunk.length; + } else { + current = total; + } + + uploadProgress = current / total; + _updateProgress(); + } + if (uploadProgress == 1) { + _otaLogger.info("File Uploaded"); + otaState = OtaState.rebooting; + beginScan( + scanReason: ScanReason.manual, + timeout: const Duration(seconds: 60), + ); // start scanning for the gear to reconnect + _timer = Timer( + const Duration(seconds: 60), + () { + if (otaState != OtaState.completed) { + _otaLogger.warning("Gear did not return correct version after reboot"); + otaState = OtaState.error; + } + }, + ); + plausible.event(name: "Update Gear"); + } + baseStatefulDevice.deviceState.value = DeviceState.standby; // release the command queue + } + } finally { + transaction.finish(); + } + } + + OtaUpdater({this.onProgress, this.onStateChanged, required this.baseStatefulDevice}) { + firmwareInfo ??= baseStatefulDevice.fwInfo.value; + WakelockPlus.enabled.then((value) => _wakelockEnabledBeforehand = value); + baseStatefulDevice.fwVersion.addListener(verListener); + baseStatefulDevice.fwInfo.addListener(fwInfoListener); + } + + void dispose() { + _timer?.cancel(); + if (!_wakelockEnabledBeforehand) { + unawaited(WakelockPlus.disable()); + } + if (!HiveProxy.getOrDefault(settings, alwaysScanning, defaultValue: alwaysScanningDefault)) { + unawaited(stopScan()); + } + } +} diff --git a/lib/Frontend/Widgets/device_type_widget.dart b/lib/Frontend/Widgets/device_type_widget.dart index e794f06f..8f52528a 100644 --- a/lib/Frontend/Widgets/device_type_widget.dart +++ b/lib/Frontend/Widgets/device_type_widget.dart @@ -1,20 +1,22 @@ import 'package:choice/choice.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../Backend/Bluetooth/bluetooth_manager.dart'; +import '../../Backend/Bluetooth/bluetooth_manager.dart'; import '../../Backend/Definitions/Device/device_definition.dart'; import '../translation_string_definitions.dart'; class DeviceTypeWidget extends ConsumerWidget { - const DeviceTypeWidget({required this.selected, required this.onSelectionChanged, super.key}); + const DeviceTypeWidget({required this.selected, required this.onSelectionChanged, this.alwaysVisible = false, super.key}); + + final bool alwaysVisible; final List selected; final Function(List value) onSelectionChanged; @override Widget build(BuildContext context, WidgetRef ref) { - if (ref.watch(knownDevicesProvider).length <= 1) { + if (ref.watch(knownDevicesProvider).length <= 1 && !alwaysVisible) { //onSelectionChanged(DeviceType.values); return Container(); } diff --git a/lib/Frontend/go_router_config.dart b/lib/Frontend/go_router_config.dart index 782d8c20..a0930b45 100644 --- a/lib/Frontend/go_router_config.dart +++ b/lib/Frontend/go_router_config.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logarte/logarte.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:tail_app/Frontend/pages/developer/bulk_ota.dart'; import '../Backend/Definitions/Action/base_action.dart'; import '../Backend/Definitions/Device/device_definition.dart'; @@ -377,6 +378,13 @@ class OtaUpdateRoute extends GoRouteData { ); } +class BulkOtaUpdateRoute extends GoRouteData { + const BulkOtaUpdateRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const BulkOTA(); +} + @TypedGoRoute( path: '/settings', name: 'Settings', @@ -397,6 +405,10 @@ class OtaUpdateRoute extends GoRouteData { path: 'log', name: 'Settings/Developer Menu/Logs', ), + TypedGoRoute( + path: 'bulkOta', + name: 'Settings/Developer Menu/bulkOta', + ), ], ), ], diff --git a/lib/Frontend/pages/developer/bulk_ota.dart b/lib/Frontend/pages/developer/bulk_ota.dart new file mode 100644 index 00000000..93937c3d --- /dev/null +++ b/lib/Frontend/pages/developer/bulk_ota.dart @@ -0,0 +1,194 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tail_app/Backend/device_registry.dart'; +import 'package:tail_app/Backend/firmware_update.dart'; +import 'package:tail_app/Frontend/Widgets/device_type_widget.dart'; + +import '../../../Backend/Definitions/Device/device_definition.dart'; + +class BulkOTA extends ConsumerStatefulWidget { + const BulkOTA({super.key}); + + @override + ConsumerState createState() => _BulkOTAState(); +} + +class _BulkOTAState extends ConsumerState { + List selectedDeviceType = DeviceType.values; + BuiltMap? updatableDevices; + + @override + void initState() { + super.initState(); + initDevices(); + } + + void initDevices() { + BuiltList devices = ref.read(getAvailableGearForTypeProvider(selectedDeviceType.toBuiltSet())); + setState(() { + updatableDevices = BuiltMap.build( + (MapBuilder p0) { + p0.addEntries( + devices.map( + (baseStatefulDevice) { + return MapEntry( + baseStatefulDevice, + OtaUpdater( + baseStatefulDevice: baseStatefulDevice, + onStateChanged: (p0) => setState(() {}), + ), + ); + }, + ), + ); + }, + ); + }); + } + + void beginOta() { + for (var device in updatableDevices!.values) { + device.beginUpdate(); + } + } + + void abort() { + for (var device in updatableDevices!.values) { + device.otaState = OtaState.error; + } + } + + @override + Widget build(BuildContext context) { + bool otaInProgress = updatableDevices!.values + .where( + (element) => [OtaState.download, OtaState.upload].contains(element.otaState), + ) + .isNotEmpty; + + return PopScope( + canPop: !otaInProgress, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + abort(); + } + }, + child: Scaffold( + appBar: AppBar( + title: Text("Update all the things"), + ), + body: ListView( + children: [ + DeviceTypeWidget( + alwaysVisible: true, + selected: selectedDeviceType, + onSelectionChanged: (value) { + setState(() { + selectedDeviceType = value; + }); + initDevices(); + }, + ), + OverflowBar( + alignment: MainAxisAlignment.center, + children: [ + FilledButton( + onPressed: otaInProgress || updatableDevices!.isEmpty + ? null + : () { + for (OtaUpdater otaUpdater in updatableDevices!.values) { + otaUpdater.beginUpdate(); + } + }, + child: Text("Begin"), + ), + FilledButton( + onPressed: !otaInProgress + ? null + : () { + for (OtaUpdater otaUpdater in updatableDevices!.values) { + otaUpdater.otaState = OtaState.error; + } + }, + child: Text("Abort"), + ), + ElevatedButton( + onPressed: updatableDevices!.isEmpty || selectedDeviceType.length != 1 + ? null + : () async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + withData: true, + allowedExtensions: ['bin'], + ); + if (result != null) { + setState(() { + for (OtaUpdater otaUpdater in updatableDevices!.values) { + otaUpdater.setManualOtaFile(result.files.single.bytes?.toList(growable: false)); + } + }); + } else { + // User canceled the picker + } + }, + child: const Text("Select file"), + ), + ], + ), + if (updatableDevices!.isNotEmpty) ...[ + ListView.builder( + shrinkWrap: true, + itemCount: updatableDevices!.length, + itemBuilder: (context, index) { + MapEntry device = updatableDevices!.entries.toList()[index]; + return OtaListItem(device: device); + }, + ), + ], + ], + ))); + } +} + +class OtaListItem extends StatefulWidget { + const OtaListItem({ + super.key, + required this.device, + }); + + final MapEntry device; + + @override + State createState() => _OtaListItemState(); +} + +class _OtaListItemState extends State { + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(widget.device.key.baseStoredDevice.name), + trailing: Text(widget.device.value.otaState.name), + subtitle: LinearProgressIndicator( + value: widget.device.value.progress, + ), + ); + } + + @override + void initState() { + super.initState(); + widget.device.value.onProgress = progressListener; + } + + @override + void dispose() { + super.dispose(); + widget.device.value.onProgress = null; + } + + void progressListener(double progress) { + setState(() {}); + } +} diff --git a/lib/Frontend/pages/developer/developer_menu.dart b/lib/Frontend/pages/developer/developer_menu.dart index 085671f0..4f09e156 100644 --- a/lib/Frontend/pages/developer/developer_menu.dart +++ b/lib/Frontend/pages/developer/developer_menu.dart @@ -35,6 +35,14 @@ class _DeveloperMenuState extends ConsumerState { const LogsRoute().push(context); }, ), + ListTile( + title: const Text("Bulk Update"), + leading: const Icon(Icons.system_update), + subtitle: const Text("Update multiple gear"), + onTap: () async { + const BulkOtaUpdateRoute().push(context); + }, + ), ListTile( title: const Text("Throw an error"), leading: const Icon(Icons.bug_report), diff --git a/lib/Frontend/pages/more.dart b/lib/Frontend/pages/more.dart index ae62d365..20b41084 100644 --- a/lib/Frontend/pages/more.dart +++ b/lib/Frontend/pages/more.dart @@ -66,6 +66,16 @@ class _MoreState extends ConsumerState { const SettingsRoute().push(context); }, ), + if (HiveProxy.getOrDefault(settings, showDebugging, defaultValue: showDebuggingDefault)) ...[ + ListTile( + title: const Text("Development Menu"), + leading: const Icon(Icons.bug_report), + subtitle: const Text("It is illegal to read this message"), + onTap: () async { + const DeveloperMenuRoute().push(context); + }, + ), + ], ListTile( leading: const Icon(Icons.feedback), title: Text(feedbackPage()), diff --git a/lib/Frontend/pages/ota_update.dart b/lib/Frontend/pages/ota_update.dart index 26edf6dd..8cde2739 100644 --- a/lib/Frontend/pages/ota_update.dart +++ b/lib/Frontend/pages/ota_update.dart @@ -1,23 +1,14 @@ import 'dart:async'; -import 'dart:convert'; import 'package:animate_do/animate_do.dart'; -import 'package:crypto/crypto.dart'; -import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../../Backend/Bluetooth/bluetooth_manager.dart'; -import '../../Backend/Bluetooth/bluetooth_manager_plus.dart'; import '../../Backend/Definitions/Device/device_definition.dart'; import '../../Backend/firmware_update.dart'; import '../../Backend/logging_wrappers.dart'; -import '../../Backend/plausible_dio.dart'; -import '../../Backend/version.dart'; import '../../constants.dart'; import '../../gen/assets.gen.dart'; import '../Widgets/lottie_lazy_load.dart'; @@ -33,56 +24,26 @@ class OtaUpdate extends ConsumerStatefulWidget { ConsumerState createState() => _OtaUpdateState(); } -enum OtaState { - standby, - download, - upload, - error, - manual, - completed, - lowBattery, - rebooting, -} - class _OtaUpdateState extends ConsumerState { - double downloadProgress = 0; - double uploadProgress = 0; - FWInfo? firmwareInfo; - Dio dio = Dio(); - List? firmwareFile; - OtaState otaState = OtaState.standby; - String? downloadedMD5; - bool wakelockEnabledBeforehand = false; + late OtaUpdater otaUpdater; BaseStatefulDevice? baseStatefulDevice; - int current = 0; - Duration timeRemainingMS = Duration.zero; - Timer? timer; - final Logger _otaLogger = Logger('otaLogger'); @override void initState() { super.initState(); baseStatefulDevice = ref.read(knownDevicesProvider)[widget.device]; - firmwareInfo ??= baseStatefulDevice?.fwInfo.value; - WakelockPlus.enabled.then((value) => wakelockEnabledBeforehand = value); - baseStatefulDevice!.fwVersion.addListener(verListener); - baseStatefulDevice!.fwInfo.addListener(fwInfoListener); + otaUpdater = OtaUpdater( + baseStatefulDevice: baseStatefulDevice!, + onProgress: (p0) => setState(() {}), + onStateChanged: (p0) => setState(() {}), + ); unawaited(ref.read(hasOtaUpdateProvider(baseStatefulDevice!).future)); } @override void dispose() { super.dispose(); - if (!wakelockEnabledBeforehand) { - unawaited(WakelockPlus.disable()); - } - baseStatefulDevice?.deviceState.value = DeviceState.standby; - baseStatefulDevice!.fwVersion.removeListener(verListener); - baseStatefulDevice!.fwInfo.removeListener(fwInfoListener); - if (!HiveProxy.getOrDefault(settings, alwaysScanning, defaultValue: alwaysScanningDefault)) { - unawaited(stopScan()); - } - timer?.cancel(); + otaUpdater.dispose(); } @override @@ -93,11 +54,11 @@ class _OtaUpdateState extends ConsumerState { child: AnimatedSwitcher( duration: animationTransitionDuration, child: Flex( - key: ValueKey(otaState), + key: ValueKey(otaUpdater.otaState), mainAxisAlignment: MainAxisAlignment.center, direction: Axis.vertical, children: [ - if ([OtaState.standby, OtaState.manual].contains(otaState)) ...[ + if ([OtaState.standby, OtaState.manual].contains(otaUpdater.otaState)) ...[ if (HiveProxy.getOrDefault(settings, showDebugging, defaultValue: showDebuggingDefault)) ...[ Expanded( child: ListTile( @@ -105,12 +66,12 @@ class _OtaUpdateState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("MD5: ${firmwareInfo?.md5sum}"), - Text("DL MD5: $downloadedMD5"), + Text("MD5: ${otaUpdater.firmwareInfo?.md5sum}"), + Text("DL MD5: ${otaUpdater.downloadedMD5}"), Text("URL: ${baseStatefulDevice?.baseDeviceDefinition.fwURL}"), - Text("AVAILABLE VERSION: ${firmwareInfo?.version}"), + Text("AVAILABLE VERSION: ${otaUpdater.firmwareInfo?.version}"), Text("CURRENT VERSION: ${baseStatefulDevice?.fwVersion.value}"), - Text("STATE: $otaState"), + Text("STATE: ${otaUpdater.otaState}"), ], ), ), @@ -129,17 +90,17 @@ class _OtaUpdateState extends ConsumerState { child: SingleChildScrollView( child: ListTile( title: Text(otaChangelogLabel()), - subtitle: Text(firmwareInfo?.changelog ?? "Unavailable"), + subtitle: Text(otaUpdater.firmwareInfo?.changelog ?? "Unavailable"), ), ), ), Expanded( child: SafeArea( - child: ButtonBar( + child: OverflowBar( alignment: MainAxisAlignment.center, children: [ FilledButton( - onPressed: (firmwareInfo != null || firmwareFile != null) ? beginUpdate : null, + onPressed: (otaUpdater.firmwareInfo != null || otaUpdater.firmwareFile != null) ? otaUpdater.beginUpdate : null, child: Row( children: [ Icon( @@ -172,11 +133,7 @@ class _OtaUpdateState extends ConsumerState { ); if (result != null) { setState(() { - firmwareFile = result.files.single.bytes?.toList(growable: false); - Digest digest = md5.convert(firmwareFile!); - downloadProgress = 1; - downloadedMD5 = digest.toString(); - otaState = OtaState.manual; + otaUpdater.setManualOtaFile(result.files.single.bytes?.toList(growable: false)); }); } else { // User canceled the picker @@ -190,7 +147,7 @@ class _OtaUpdateState extends ConsumerState { ), ), ], - if (otaState == OtaState.completed) ...[ + if (otaUpdater.otaState == OtaState.completed) ...[ Expanded( child: Center( child: ListTile( @@ -212,7 +169,7 @@ class _OtaUpdateState extends ConsumerState { ), ), ], - if (otaState == OtaState.error) ...[ + if (otaUpdater.otaState == OtaState.error) ...[ Expanded( child: Center( child: ListTile( @@ -234,7 +191,7 @@ class _OtaUpdateState extends ConsumerState { ), ), ], - if (otaState == OtaState.lowBattery) ...[ + if (otaUpdater.otaState == OtaState.lowBattery) ...[ Expanded( child: Center( child: ListTile( @@ -256,7 +213,7 @@ class _OtaUpdateState extends ConsumerState { ), ), ], - if ([OtaState.download, OtaState.upload, OtaState.rebooting].contains(otaState)) ...[ + if ([OtaState.download, OtaState.upload, OtaState.rebooting].contains(otaUpdater.otaState)) ...[ Expanded( child: Center( child: ListTile( @@ -288,8 +245,7 @@ class _OtaUpdateState extends ConsumerState { child: ListTile( subtitle: Builder( builder: (context) { - double progress = downloadProgress < 1 ? downloadProgress : uploadProgress; - return LinearProgressIndicator(value: otaState == OtaState.rebooting ? null : progress); + return LinearProgressIndicator(value: otaUpdater.otaState == OtaState.rebooting ? null : otaUpdater.progress); }, ), ), @@ -301,9 +257,9 @@ class _OtaUpdateState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Upload Progress: $current / ${firmwareFile?.length} = ${uploadProgress.toStringAsPrecision(3)}'), + Text('Upload Progress: ${otaUpdater.current} / ${otaUpdater.firmwareFile?.length} = ${otaUpdater.uploadProgress.toStringAsPrecision(3)}'), Text('MTU: ${baseStatefulDevice!.mtu.value}'), - Text('OtaState: ${otaState.name}'), + Text('OtaState: ${otaUpdater.otaState.name}'), Text('DeviceState: ${baseStatefulDevice!.deviceState.value}'), Text('ConnectivityState: ${baseStatefulDevice!.deviceConnectionState.value}'), ], @@ -318,174 +274,4 @@ class _OtaUpdateState extends ConsumerState { ), ); } - - Future beginUpdate() async { - if (baseStatefulDevice!.batteryLevel.value < 50) { - setState(() { - otaState = OtaState.lowBattery; - }); - return; - } - WakelockPlus.enable(); - if (firmwareFile == null) { - await downloadFirmware(); - } - if (otaState != OtaState.error) { - await uploadFirmware(); - } - } - - Future downloadFirmware() async { - if (firmwareInfo == null) { - return; - } - setState(() { - otaState = OtaState.download; - downloadProgress = 0; - }); - final transaction = Sentry.startTransaction('OTA Download', 'http')..setTag("GearType", baseStatefulDevice!.baseDeviceDefinition.btName); - try { - final Response> rs = await (await initDio()).get>( - firmwareInfo!.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!); - downloadedMD5 = digest.toString(); - if (digest.toString() == firmwareInfo!.md5sum) { - firmwareFile = rs.data; - } else { - transaction.status = const SpanStatus.dataLoss(); - otaState = OtaState.error; - } - } - } catch (e) { - transaction - ..throwable = e - ..status = const SpanStatus.internalError(); - otaState = OtaState.error; - } - transaction.finish(); - } - - Future verListener() async { - Version version = baseStatefulDevice!.fwVersion.value; - FWInfo? fwInfo = firmwareInfo; - if (fwInfo != null && version.compareTo(const Version()) > 0 && otaState == OtaState.rebooting) { - bool updated = version.compareTo(getVersionSemVer(fwInfo.version)) >= 0; - if (mounted) { - setState(() { - otaState = updated ? OtaState.completed : OtaState.error; - }); - } - } - } - - void fwInfoListener() { - setState(() { - firmwareInfo = baseStatefulDevice!.fwInfo.value; - }); - } - - Future uploadFirmware() async { - setState(() { - otaState = OtaState.upload; - uploadProgress = 0; - if (baseStatefulDevice == null) { - otaState = OtaState.error; - return; - } - }); - - Stopwatch timeToUpdate = Stopwatch(); - final transaction = Sentry.startTransaction('updateGear()', 'task'); - try { - if (firmwareFile != null && baseStatefulDevice != null) { - transaction.setTag("GearType", baseStatefulDevice!.baseDeviceDefinition.btName); - baseStatefulDevice?.gearReturnedError.value = false; - int mtu = baseStatefulDevice!.mtu.value - 10; - int total = firmwareFile!.length; - current = 0; - baseStatefulDevice!.gearReturnedError.value = false; - - _otaLogger.info("Holding the command queue"); - timeToUpdate.start(); - _otaLogger.info("Send OTA begin message"); - List beginOTA = List.from(const Utf8Encoder().convert("OTA ${firmwareFile!.length} $downloadedMD5")); - await sendMessage(baseStatefulDevice!, beginOTA); - - while (uploadProgress < 1 && otaState != OtaState.error && mounted) { - baseStatefulDevice!.deviceState.value = DeviceState.busy; // hold the command queue - if (baseStatefulDevice!.gearReturnedError.value) { - transaction.status = const SpanStatus.unavailable(); - if (mounted) { - setState(() { - otaState = OtaState.error; - }); - } - break; - } - - List chunk = firmwareFile!.skip(current).take(mtu).toList(); - if (chunk.isNotEmpty) { - try { - //_otaLogger.info("Updating $uploadProgress"); - if (current > 0) { - timeRemainingMS = Duration(milliseconds: ((timeToUpdate.elapsedMilliseconds / current) * (total - current)).toInt()); - } - - await sendMessage(baseStatefulDevice!, chunk, withoutResponse: true); - } catch (e, s) { - _otaLogger.severe("Exception during ota upload:$e", e, s); - if ((current + chunk.length) / total < 0.99) { - transaction - ..status = const SpanStatus.unknownError() - ..throwable = e; - setState(() { - otaState = OtaState.error; - }); - return; - } - } - current = current + chunk.length; - } else { - current = total; - } - - setState(() { - uploadProgress = current / total; - }); - } - if (uploadProgress == 1) { - _otaLogger.info("File Uploaded"); - otaState = OtaState.rebooting; - beginScan( - scanReason: ScanReason.manual, - timeout: const Duration(seconds: 60), - ); // start scanning for the gear to reconnect - timer = Timer( - const Duration(seconds: 60), - () { - if (otaState != OtaState.completed && mounted) { - setState(() { - _otaLogger.warning("Gear did not return correct version after reboot"); - otaState = OtaState.error; - }); - } - }, - ); - plausible.event(name: "Update Gear"); - } - baseStatefulDevice!.deviceState.value = DeviceState.standby; // release the command queue - } - } finally { - transaction.finish(); - } - } } diff --git a/lib/Frontend/pages/settings.dart b/lib/Frontend/pages/settings.dart index e11cc582..34740931 100644 --- a/lib/Frontend/pages/settings.dart +++ b/lib/Frontend/pages/settings.dart @@ -227,16 +227,6 @@ class _SettingsState extends ConsumerState { }, ), ), - if (HiveProxy.getOrDefault(settings, showDebugging, defaultValue: showDebuggingDefault)) ...[ - ListTile( - title: const Text("Development Menu"), - leading: const Icon(Icons.bug_report), - subtitle: const Text("It is illegal to read this message"), - onTap: () async { - const DeveloperMenuRoute().push(context); - }, - ), - ], ], ), ); diff --git a/lib/Frontend/translation_string_definitions.dart b/lib/Frontend/translation_string_definitions.dart index 875b88c0..ab40f79f 100644 --- a/lib/Frontend/translation_string_definitions.dart +++ b/lib/Frontend/translation_string_definitions.dart @@ -95,7 +95,8 @@ String sequencesEditDeleteTitle() => Intl.message('Delete Action', name: 'sequen String sequencesEditDeleteDescription() => Intl.message('Are you sure you want to delete this action?', name: 'sequencesEditDeleteDescription', desc: 'Message of the dialog on the sequence edit page to delete the sequence'); -String sequenceEditListDelayLabel(int howMany) => Intl.message( +String sequenceEditListDelayLabel(int howMany) => + Intl.message( 'Delay next move for $howMany ms.', name: 'sequenceEditListDelayLabel', args: [howMany], @@ -276,19 +277,22 @@ String otaFailedTitle() => Intl.message("Update Failed. Please restart your gear String otaLowBattery() => Intl.message("Low Battery. Please charge your gear to at least 50%", name: 'otaLowBattery', desc: 'Title for the text that appears when an OTA update was blocked due to low battery'); -String triggerInfoDescription() => Intl.message( +String triggerInfoDescription() => + Intl.message( 'Triggers automatically send actions to your gear. You can have multiple triggers active at the same time. Tap on a trigger to edit it, Use the toggle on the left to enable the trigger.', name: 'triggerInfoDescription', desc: 'Description for what a trigger is and how to use them on the triggers page', ); -String triggerInfoEditActionDescription() => Intl.message( +String triggerInfoEditActionDescription() => + Intl.message( "Tap the pencil to select the Action to play when the event happens. An action will be randomly selected that is compatible with connected gear. GlowTip and Sound actions will trigger alongside Move actions.", name: 'triggerInfoEditActionDescription', desc: 'Instruction on how to select an action on the trigger edit page', ); -String sequencesInfoDescription() => Intl.message( +String sequencesInfoDescription() => + Intl.message( 'Custom Actions allow you to make your own Actions for gear. Tapping on a Custom Action will play it. Tap the pencil to edit a Custom Action. Please make sure your gear firmware is up to date.', name: 'sequencesInfoDescription', desc: 'Description for what a custom action is and how to use them on the Custom Actions page', @@ -345,13 +349,15 @@ String scanAddDemoGear() => Intl.message("Add Fake Gear", name: 'scanAddDemoGear String scanRemoveDemoGear() => Intl.message("Remove all fake gear", name: 'scanRemoveDemoGear', desc: 'Label for the button to remove all demo gear on the scan for new devices page'); -String scanDemoGearTip() => Intl.message( +String scanDemoGearTip() => + Intl.message( "Want to try out the app but are waiting for your gear to arrive? Add a fake gear. This lets you experience the app as if you had your gear, or if you want to try out gear you currently do not own. This enables a new section on the 'Scan For New Gear' page.", name: 'scanDemoGearTip', desc: 'Tip Card description for the demo gear on the scan for new devices page', ); -String triggerActionSelectorTutorialLabel() => Intl.message( +String triggerActionSelectorTutorialLabel() => + Intl.message( "Select as many actions as you want. An action will be randomly selected that is compatible with connected gear. GlowTip and Sound actions will trigger alongside Move actions. Don't forget to save.", name: 'triggerActionSelectorTutorialLabel', desc: 'Label for the tutorial card on the Action selector for triggers', @@ -369,7 +375,7 @@ String newGearConnectedSnackbarTitle() => Intl.message("Gear Connected", name: ' String newGearConnectedSnackbarLabel() => Intl.message("Happy Wagging", name: 'newGearConnectedSnackbarLabel', desc: 'Label for the snackbar that appears when new gear is paired and connected'); -String earSpeedTitle() => Intl.message("Ear Twist Speed", name: 'earSpeedTitle', desc: 'Title for the ear speed widget that appears when ears are connected'); +String earSpeedTitle() => Intl.message("Ear Move Speed", name: 'earSpeedTitle', desc: 'Title for the ear speed widget that appears when ears are connected'); String earSpeedFast() => Intl.message("Fast", name: 'earSpeedFast', desc: 'Label for the fast option on the ear speed widget that appears when ears are connected'); diff --git a/pubspec.lock b/pubspec.lock index c1889813..e37e1776 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -428,10 +428,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0" + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" url: "https://pub.dev" source: hosted - version: "5.6.0" + version: "5.7.0" dio_smart_retry: dependency: "direct main" description: @@ -570,10 +570,10 @@ packages: dependency: "direct main" description: name: flutter_adaptive_scaffold - sha256: "93d20f0a4fbded8755571cc1f11a05a116cdc401ebaa15293ea2f87618fa2d18" + sha256: "6b587d439c7da037432bbfc78d9676e1d08f2d7490f08e8d689a20f08e049802" url: "https://pub.dev" source: hosted - version: "0.2.4" + version: "0.2.6" flutter_android_volume_keydown: dependency: "direct main" description: @@ -595,10 +595,10 @@ packages: dependency: "direct main" description: name: flutter_foreground_task - sha256: "4962ffefe4352435900eb25734925f1ad002abf48e13c1ca22c9c63391be4f94" + sha256: "6e0b5de3d1cceb3bd608793af0dde3346194465f9f664fdb8bd87638dbe847e9" url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.1+1" flutter_gen_core: dependency: transitive description: @@ -1430,42 +1430,42 @@ packages: dependency: transitive description: name: sentry - sha256: "1af8308298977259430d118ab25be8e1dda626cdefa1e6ce869073d530d39271" + sha256: "033287044a6644a93498969449d57c37907e56f5cedb17b88a3ff20a882261dd" url: "https://pub.dev" source: hosted - version: "8.8.0" + version: "8.9.0" sentry_dio: dependency: "direct main" description: name: sentry_dio - sha256: "204ad3d22f6cb653d20350531d3cf0ec90ba98931c6ce6fb8e9fbb6706daf297" + sha256: "154f32c2381cb53c2687601b0e6a54eeda452b5d1d414b1c9e1c0ea817e65ced" url: "https://pub.dev" source: hosted - version: "8.8.0" + version: "8.9.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "18fe4d125c2d529bd6127200f0d2895768266a8c60b4fb50b2086fd97e1a4ab2" + sha256: "3780b5a0bb6afd476857cfbc6c7444d969c29a4d9bd1aa5b6960aa76c65b737a" url: "https://pub.dev" source: hosted - version: "8.8.0" + version: "8.9.0" sentry_hive: dependency: "direct main" description: name: sentry_hive - sha256: "69b821b3db699690f4d83d79332bb5d969d23da84282e633b5a077449fda8bc5" + sha256: "7375199dc6a7daff2902e54859ed538860ddf29bdfee5ff101e4479cf8214d21" url: "https://pub.dev" source: hosted - version: "8.8.0" + version: "8.9.0" sentry_logging: dependency: "direct main" description: name: sentry_logging - sha256: c5bab2abb3e6419532676184f5c27c5e953556f678729cde4f4ff078101115a4 + sha256: f798a08dc7931f1a86ffd2d2d9b38b2dbeeb4df05a91cd5cd47606ed89408ee7 url: "https://pub.dev" source: hosted - version: "8.8.0" + version: "8.9.0" shake: dependency: "direct main" description: @@ -1732,10 +1732,10 @@ packages: dependency: "direct main" description: name: upgrader - sha256: d45483694620883107c2f5ca1dff7cdd4237b16810337a9c9c234203eb79eb5f + sha256: "95f9fc010e29cfc8c5383d51c97285712817a9ede615d6ba1df2bd081660c5c9" url: "https://pub.dev" source: hosted - version: "10.3.0" + version: "11.1.0" url_launcher: dependency: "direct main" description: @@ -1956,10 +1956,10 @@ packages: dependency: "direct main" description: name: wordpress_client - sha256: c4f10cddaa83e438f35c6a78900e9e003bbcfe08c59691f377272de576cf2f68 + sha256: "89218f33edf14b6dee004f155d3944137f098b310f4d67cda792ec36141c9d62" url: "https://pub.dev" source: hosted - version: "8.5.3" + version: "8.5.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8304472c..7e1bf79a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: json_annotation: ^4.9.0 crypto: ^3.0.5 # used for md5 hash checking during ota download circular_buffer: ^0.11.0 # Used for serial console - wordpress_client: ^8.5.3 # Used for tail blog + wordpress_client: ^8.5.4 # Used for tail blog logarte: git: url: https://github.com/Codel1417/logarte @@ -39,7 +39,7 @@ dependencies: permission_handler: ^11.3.1 url_launcher: ^6.3.0 # Open URLS in external apps flutter_blue_plus: ^1.32.12 - flutter_foreground_task: ^8.7.0 # Keep the app running in the background on android + flutter_foreground_task: ^8.8.1+1 # Keep the app running in the background on android install_referrer: # Needs gradle namespace git: url: https://github.com/undreeyyy/flutter_plugin_install_referrer @@ -59,7 +59,7 @@ dependencies: flutter_screen_lock: ^9.1.0 # used to hide dev mode toggle introduction_screen: ^3.1.14 # Onboarding flex_color_picker: ^3.5.1 - flutter_adaptive_scaffold: ^0.2.4 + flutter_adaptive_scaffold: ^0.2.6 animate_do: ^3.3.4 fl_chart: ^0.69.0 # Used for the battery graph chart_sparkline: ^1.1.1 # used for the move easing visual @@ -80,7 +80,7 @@ dependencies: lottie: ^3.1.2 # Dio HTTP - dio: ^5.6.0 + dio: ^5.7.0 dio_smart_retry: ^6.0.0 # Sensors @@ -106,16 +106,16 @@ dependencies: # play services in_app_review: ^2.0.9 - upgrader: ^10.3.0 + upgrader: ^11.1.0 # Spicy plausible_analytics: ^0.3.0 # Privacy Preserving analytics # Sentry - sentry_flutter: ^8.8.0 # Base sentry + Flutter integration - sentry_logging: ^8.8.0 # Collects app logs - sentry_hive: ^8.8.0 # Collects Hive storage accesses - sentry_dio: ^8.8.0 # Collects Dio HTTP requests + sentry_flutter: ^8.9.0 # Base sentry + Flutter integration + sentry_logging: ^8.9.0 # Collects app logs + sentry_hive: ^8.9.0 # Collects Hive storage accesses + sentry_dio: ^8.9.0 # Collects Dio HTTP requests feedback_sentry: # need to update for sentry git: url: https://github.com/ueman/feedback