diff --git a/app/lib/pages/home/firmware_update.dart b/app/lib/pages/home/firmware_update.dart index 109393ec1e..19807c5ac8 100644 --- a/app/lib/pages/home/firmware_update.dart +++ b/app/lib/pages/home/firmware_update.dart @@ -6,7 +6,6 @@ import 'package:omi/pages/home/firmware_mixin.dart'; import 'package:omi/pages/home/page.dart'; import 'package:omi/utils/analytics/intercom.dart'; import 'package:omi/utils/other/temp.dart'; -import 'package:gradient_borders/gradient_borders.dart'; class FirmwareUpdate extends StatefulWidget { final BtDevice? device; @@ -51,8 +50,6 @@ class _FirmwareUpdateState extends State with FirmwareMixin { Future _selectLocalFirmwareFile() async { try { - // Here you would implement file picking functionality - // For example using file_picker package: final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['zip'], @@ -60,7 +57,6 @@ class _FirmwareUpdateState extends State with FirmwareMixin { if (result != null) { String filePath = result.files.single.path!; - // Start firmware update with local file await startDfu(widget.device!, zipFilePath: filePath); } } catch (e) { @@ -73,6 +69,410 @@ class _FirmwareUpdateState extends State with FirmwareMixin { } } + Widget _buildProgressSection() { + return Card( + color: Colors.black.withOpacity(0.2), + elevation: 2.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withOpacity(0.1)), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isDownloading ? 'Downloading Firmware' : 'Installing Firmware', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + '${isDownloading ? downloadProgress : installProgress}%', + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 16), + Stack( + children: [ + Container( + height: 8, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + ), + LayoutBuilder(builder: (context, constraints) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + width: constraints.maxWidth * ((isInstalling ? installProgress : downloadProgress) / 100), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.purpleAccent.shade100, + Colors.deepPurple.shade300, + ], + ), + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ], + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.yellow.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.yellow.withOpacity(0.3)), + ), + child: const Row( + children: [ + Icon(Icons.warning_rounded, color: Colors.yellow, size: 24), + SizedBox(width: 12), + Expanded( + child: Text( + 'Please do not close the app or turn off the device.\nIt could result in a corrupted device.', + style: TextStyle( + color: Colors.yellow, + fontSize: 14, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSuccessSection() { + return Card( + color: Colors.black.withOpacity(0.2), + elevation: 2.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withOpacity(0.1)), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle_outline_rounded, + color: Colors.green, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Firmware Updated Successfully', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Please restart your ${widget.device?.name ?? "Omi device"} to complete the update', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.white.withOpacity(0.8), + ), + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color.fromARGB(127, 208, 208, 208), + Color.fromARGB(127, 188, 99, 121), + Color.fromARGB(127, 86, 101, 182), + Color.fromARGB(127, 126, 190, 236), + ], + ), + borderRadius: BorderRadius.circular(12), + ), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () async { + routeToPage(context, const HomePageWrapper(), replace: true); + }, + child: const Text( + "Done", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildUpdateSection() { + bool hasChangelog = latestFirmwareDetails['changelog'] != null && + (List.from(latestFirmwareDetails['changelog'])).isNotEmpty; + + return Card( + color: Colors.black.withOpacity(0.2), + elevation: 2.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withOpacity(0.1)), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + children: [ + Text( + 'Current Version', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.6), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.memory, size: 20, color: Colors.deepPurple.shade100), + const SizedBox(width: 8), + Text( + widget.device!.firmwareRevision, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ], + ), + if (latestFirmwareDetails['version'] != null) ...[ + Icon(Icons.arrow_forward_ios_rounded, size: 20, color: Colors.white.withOpacity(0.4)), + Column( + children: [ + Text( + 'Latest Version', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.6), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.system_update_alt_rounded, size: 20, color: Colors.purpleAccent.shade100), + const SizedBox(width: 8), + Text( + '${latestFirmwareDetails['version']}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ], + ), + ], + ], + ), + const SizedBox(height: 24), + if (hasChangelog) ...[ + Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + iconColor: Colors.white.withOpacity(0.6), + collapsedIconColor: Colors.white.withOpacity(0.6), + tilePadding: EdgeInsets.zero, + title: Text( + 'View Changelog', + style: TextStyle( + color: Colors.deepPurple.shade50, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 16.0, left: 16.0, right: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...(List.from(latestFirmwareDetails['changelog'])).map((change) => Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0, top: 5.0), + child: + Icon(Icons.fiber_manual_record, size: 8, color: Colors.deepPurple.shade100), + ), + Expanded( + child: Text( + change, + style: + TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, height: 1.3), + ), + ), + ], + ), + )), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + if (updateMessage != '0') ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.1))), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, color: Colors.purpleAccent.shade100, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + updateMessage, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + if (updateMessage == '0') ...[ + TextButton.icon( + onPressed: () async { + await IntercomManager.instance.displayFirmwareUpdateArticle(); + }, + icon: Icon(Icons.help_outline, color: Colors.white.withOpacity(0.8)), + label: Text( + 'Open Update Guide', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 15, + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 16), + ], + if (shouldUpdate) ...[ + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color.fromARGB(127, 208, 208, 208), + Color.fromARGB(127, 188, 99, 121), + Color.fromARGB(127, 86, 101, 182), + Color.fromARGB(127, 126, 190, 236), + ], + ), + borderRadius: BorderRadius.circular(12), + ), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () async { + if (otaUpdateSteps.isEmpty) { + await downloadFirmware(); + await startDfu(widget.device!); + } else { + showDialog( + context: context, + builder: (context) => FirmwareUpdateDialog( + steps: otaUpdateSteps, + onUpdateStart: () async { + await downloadFirmware(); + await startDfu(widget.device!); + }, + ), + ); + } + }, + child: Text( + otaUpdateSteps.isEmpty ? "Start Update" : "Update", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return PopScope( @@ -80,189 +480,36 @@ class _FirmwareUpdateState extends State with FirmwareMixin { child: Scaffold( backgroundColor: Theme.of(context).colorScheme.primary, appBar: AppBar( - title: const Text('Firmware Update'), - backgroundColor: Theme.of(context).colorScheme.primary, - // actions: [ - // IconButton( - // icon: const Icon(Icons.file_upload, color: Colors.white), - // tooltip: 'Select local file', - // onPressed: () async { - // // Implement local file selection for firmware upgrade - // await _selectLocalFirmwareFile(); - // }, - // ), - // ], + title: const Text( + 'Firmware Update', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: Colors.transparent, + elevation: 0, ), - body: Center( - child: isLoading - ? const CircularProgressIndicator( - color: Colors.white, - ) - : Padding( - padding: const EdgeInsets.fromLTRB(14.0, 0, 14, 14), - child: isDownloading || isInstalling - ? Padding( - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 60), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(isDownloading - ? 'Downloading Firmware $downloadProgress%' - : 'Installing Firmware $installProgress%'), - const SizedBox(height: 10), - LinearProgressIndicator( - value: (isInstalling ? installProgress : downloadProgress) / 100, - valueColor: const AlwaysStoppedAnimation(Colors.white), - backgroundColor: Colors.grey[800], - ), - const SizedBox(height: 18), - const Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.warning, color: Colors.yellow), - SizedBox(width: 10), - Text( - 'Please do not close the app or turn off the device.\nIt could result in a corrupted device.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white), - ), - ], - ), - ], - ), - ) + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: isLoading + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 16), + Text( + 'Checking for updates...', + style: TextStyle(color: Colors.white.withOpacity(0.8)), + ), + ], + ) + : isDownloading || isInstalling + ? _buildProgressSection() : isInstalled - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Firmware Updated Successfully'), - const SizedBox(height: 10), - Text( - 'Please restart your ${widget.device?.name ?? "Omi device"} to complete the update', - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), - decoration: BoxDecoration( - border: const GradientBoxBorder( - gradient: LinearGradient(colors: [ - Color.fromARGB(127, 208, 208, 208), - Color.fromARGB(127, 188, 99, 121), - Color.fromARGB(127, 86, 101, 182), - Color.fromARGB(127, 126, 190, 236) - ]), - width: 2, - ), - borderRadius: BorderRadius.circular(12), - ), - child: TextButton( - style: TextButton.styleFrom( - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onPressed: () async { - routeToPage(context, const HomePageWrapper(), replace: true); - }, - child: const Text( - "Finalize", - style: TextStyle(color: Colors.white, fontSize: 16), - ), - ), - ) - ], - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Current Version: ${widget.device!.firmwareRevision}', - style: const TextStyle(color: Colors.white, fontSize: 16), - ), - const SizedBox(height: 8), - if (latestFirmwareDetails['version'] != null) ...[ - Text( - 'Latest Version Available: ${latestFirmwareDetails['version']}', - style: const TextStyle(color: Colors.white, fontSize: 16), - ), - const SizedBox(height: 16), - ], - if (updateMessage == '0') - TextButton( - onPressed: () async { - await IntercomManager.instance.displayFirmwareUpdateArticle(); - }, - style: TextButton.styleFrom( - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - child: const Text( - 'Open Update Guide', - style: TextStyle( - color: Colors.white, - fontSize: 16, - decoration: TextDecoration.underline, - ), - ), - ), - if (updateMessage != '0') - Text( - updateMessage, - style: const TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - shouldUpdate - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), - decoration: BoxDecoration( - border: const GradientBoxBorder( - gradient: LinearGradient(colors: [ - Color.fromARGB(127, 208, 208, 208), - Color.fromARGB(127, 188, 99, 121), - Color.fromARGB(127, 86, 101, 182), - Color.fromARGB(127, 126, 190, 236) - ]), - width: 2, - ), - borderRadius: BorderRadius.circular(12), - ), - child: TextButton( - style: TextButton.styleFrom( - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onPressed: () async { - if (otaUpdateSteps.isEmpty) { - await downloadFirmware(); - await startDfu(widget.device!); - } else { - showDialog( - context: context, - builder: (context) => FirmwareUpdateDialog( - steps: otaUpdateSteps, - onUpdateStart: () async { - await downloadFirmware(); - await startDfu(widget.device!); - }, - ), - ); - } - }, - child: Text( - otaUpdateSteps.isEmpty ? "Start Update" : "Update", - style: const TextStyle(color: Colors.white, fontSize: 16), - ), - ), - ) - : const SizedBox.shrink(), - ], - ), - ), + ? _buildSuccessSection() + : _buildUpdateSection(), + ), + ), ), ), ); diff --git a/backend/routers/firmware.py b/backend/routers/firmware.py index de4c7b6fca..8f008e7e07 100644 --- a/backend/routers/firmware.py +++ b/backend/routers/firmware.py @@ -106,21 +106,41 @@ async def get_latest_version(device_model: str, firmware_revision: str, hardware assets = release_data.get("assets") asset = None for a in assets: - if "ota" in a.get("name", "").lower(): + if "ota" in a.get("name", "").lower() and a.get("name", "").endswith(".zip"): asset = a break if not asset: raise HTTPException(status_code=500, detail="No OTA zip found in the release") + # Safely get values with defaults + version = kv.get("release_firmware_version") + min_version = kv.get("minimum_firmware_required") + min_app_version = kv.get("minimum_app_version") + min_app_version_code = kv.get("minimum_app_version_code") + changelog_text = kv.get("changelog", "") + ota_steps = kv.get('ota_update_steps', []) + is_legacy_dfu_str = kv.get('is_legacy_secure_dfu', 'True') + + # Attempt to parse boolean, default to True on error + try: + is_legacy_dfu = ast.literal_eval(is_legacy_dfu_str.capitalize()) + except (ValueError, SyntaxError): + is_legacy_dfu = True + + # Basic validation (optional but recommended) + if not all([version, asset.get("browser_download_url")]): + raise HTTPException(status_code=500, detail="Essential release information missing") + return { - "version": kv.get("release_firmware_version"), - "min_version": kv.get("minimum_firmware_required"), - "min_app_version": kv.get("minimum_app_version"), - "min_app_version_code": kv.get("minimum_app_version_code"), + "version": version, + "min_version": min_version, + "min_app_version": min_app_version, + "min_app_version_code": min_app_version_code, "zip_url": asset.get("browser_download_url"), "draft": False, - "ota_update_steps": kv.get('ota_update_steps', []), - "is_legacy_secure_dfu": ast.literal_eval(kv.get('is_legacy_secure_dfu', 'True')), + "ota_update_steps": ota_steps, + "is_legacy_secure_dfu": is_legacy_dfu, + "changelog": changelog_text, } @@ -187,24 +207,36 @@ async def get_latest_version_v1(device: int): def extract_key_value_pairs(markdown_content): + if not markdown_content: + return {} + key_value_pattern = re.compile(r'', re.DOTALL) key_value_match = key_value_pattern.search(markdown_content) if not key_value_match: return {} - key_value_string = key_value_match.group(1) + key_value_string = key_value_match.group(1).strip() lines = key_value_string.split('\n') key_value_map = {} for line in lines: - key_value = line.split(':') - if len(key_value) == 2: - key = key_value[0].strip() - value = key_value[1].strip() + line = line.strip() + if not line: + continue + + # Use split with maxsplit=1 to handle values containing colons + parts = line.split(':', 1) + if len(parts) == 2: + key = parts[0].strip() + value = parts[1].strip() + if key == 'ota_update_steps': - # Parse comma-separated steps - key_value_map[key] = [step.strip() for step in value.split(',')] + # Split by comma, filter empty strings + key_value_map[key] = [step.strip() for step in value.split(',') if step.strip()] + elif key == 'changelog': + # Split by pipe, filter empty strings + key_value_map[key] = [item.strip() for item in value.split('|') if item.strip()] else: key_value_map[key] = value