From 629bf3a85cf7ccb3cf3b469c1ecda05daa3abe3d Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 14:39:27 +0100 Subject: [PATCH 1/2] add beta firmware images to image repository --- .../repository/firmware_image_repository.dart | 7 +- .../unified_firmware_image_repository.dart | 192 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 lib/src/fota/repository/unified_firmware_image_repository.dart diff --git a/lib/src/fota/repository/firmware_image_repository.dart b/lib/src/fota/repository/firmware_image_repository.dart index 6593b2d..4183e87 100644 --- a/lib/src/fota/repository/firmware_image_repository.dart +++ b/lib/src/fota/repository/firmware_image_repository.dart @@ -15,7 +15,12 @@ class FirmwareImageRepository { throw Exception('Failed to fetch release data'); } - final releases = jsonDecode(response.body); + final releases = (jsonDecode(response.body) as List) + .where( + (release) => + release['prerelease'] != true && release['draft'] != true, + ) + .toList(); List firmwares = []; for (final release in releases) { final assets = release['assets'] as List; diff --git a/lib/src/fota/repository/unified_firmware_image_repository.dart b/lib/src/fota/repository/unified_firmware_image_repository.dart new file mode 100644 index 0000000..a00f22a --- /dev/null +++ b/lib/src/fota/repository/unified_firmware_image_repository.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +class UnifiedFirmwareRepository { + final FirmwareImageRepository _stableRepository = FirmwareImageRepository(); + + List? _cachedStable; + List? _cachedBeta; + DateTime? _lastFetchTime; + + static const _cacheDuration = Duration(minutes: 15); + + /// Fetch stable releases + Future> getStableFirmwares({ + bool forceRefresh = false, + }) async { + if (!forceRefresh && _cachedStable != null && !_isCacheExpired()) { + return _cachedStable!; + } + + final firmwares = await _stableRepository.getFirmwareImages(); + _cachedStable = firmwares + .map( + (fw) => FirmwareEntry( + firmware: fw, + source: FirmwareSource.stable, + ), + ) + .toList(); + _lastFetchTime = DateTime.now(); + return _cachedStable!; + } + + bool _isCacheExpired() { + if (_lastFetchTime == null) return true; + return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; + } + + /// Fetch all firmwares (stable + beta) + Future> getAllFirmwares({ + bool includeBeta = false, + }) async { + final stable = await getStableFirmwares(); + if (!includeBeta) { + return stable; + } + final beta = await getBetaFirmwares(); + return [...stable, ...beta]; + } + + Future> getBetaFirmwares({ + bool forceRefresh = false, + }) async { + if (!forceRefresh && _cachedBeta != null && !_isCacheExpired()) { + return _cachedBeta!; + } + + const org = 'OpenEarable'; + + const repo = 'open-earable-2'; + const prereleaseTag = 'pr-builds'; + + try { + final releaseResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$org/$repo/releases/tags/$prereleaseTag', + ), + headers: { + 'Accept': 'application/vnd.github.v3+json', + }, + ); + + if (releaseResponse.statusCode != 200) { + return []; + } + + final releaseJson = + jsonDecode(releaseResponse.body) as Map; + final assets = + (releaseJson['assets'] as List).cast>(); + + final fotaAssets = assets.where((asset) { + final name = asset['name'] as String? ?? ''; + return name.endsWith('fota.zip'); + }).toList(); + + final Map> prMap = {}; + + for (final asset in fotaAssets) { + final name = asset['name'] as String; + final match = RegExp(r'^pr-(\d+)-(.+?)-openearable_v2_fota\.zip$') + .firstMatch(name); + + if (match != null) { + final prNumber = int.parse(match.group(1)!); + final prTitle = match.group(2)!.replaceAll('_', ' '); + + prMap[prNumber] = { + 'asset': asset, + 'title': prTitle, + 'prNumber': prNumber, + }; + } + } + + final result = []; + + for (final entry in prMap.entries) { + final prNumber = entry.key; + final assetData = entry.value; + + //Commented out because GitHub API rate limits are exceeded quickly + /* + final prResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$owner/$repo/pulls/$prNumber'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + + if (prResponse.statusCode == 200) { + final prJson = jsonDecode(prResponse.body) as Map; + final prTitle = prJson['title'] as String; + final prSha = prJson['head']['sha'] as String; + final asset = assetData['asset'] as Map; + + result.add(FirmwareEntry( + firmware: RemoteFirmware( + name: prTitle, + url: asset['browser_download_url'] as String, + version: prSha.substring(0, 7), + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + )); + } else { + */ + final asset = assetData['asset'] as Map; + result.add( + FirmwareEntry( + firmware: RemoteFirmware( + name: assetData['title'] + as String, // Already sanitized from filename + url: asset['browser_download_url'] as String, + version: 'PR #$prNumber', + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + ), + ); + } + + result.sort((a, b) { + final aNum = + int.tryParse(a.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? + 0; + final bNum = + int.tryParse(b.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? + 0; + return bNum.compareTo(aNum); + }); + + _cachedBeta = result; + return _cachedBeta!; + } catch (e) { + print('Error fetching beta firmwares: $e'); + return []; + } + } + + void clearCache() { + _cachedStable = null; + _cachedBeta = null; + _lastFetchTime = null; + } +} + +enum FirmwareSource { stable, beta } + +class FirmwareEntry { + final RemoteFirmware firmware; + final FirmwareSource source; + + FirmwareEntry({ + required this.firmware, + required this.source, + }); + + bool get isBeta => source == FirmwareSource.beta; + bool get isStable => source == FirmwareSource.stable; +} From dcd9b31533a9f50fbfd806513f2316b775697fb9 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 14:54:14 +0100 Subject: [PATCH 2/2] refactor out beta image repository into new class --- lib/src/fota/fota.dart | 1 + .../repository/beta_image_repository.dart | 82 +++++++++ .../unified_firmware_image_repository.dart | 158 ++++-------------- 3 files changed, 112 insertions(+), 129 deletions(-) create mode 100644 lib/src/fota/repository/beta_image_repository.dart diff --git a/lib/src/fota/fota.dart b/lib/src/fota/fota.dart index 9dd4956..194dac1 100644 --- a/lib/src/fota/fota.dart +++ b/lib/src/fota/fota.dart @@ -3,3 +3,4 @@ export 'model/firmware_update_request.dart'; export 'model/firmware_image.dart'; export 'providers/firmware_update_request_provider.dart'; export 'repository/firmware_image_repository.dart'; +export 'repository/unified_firmware_image_repository.dart'; diff --git a/lib/src/fota/repository/beta_image_repository.dart b/lib/src/fota/repository/beta_image_repository.dart new file mode 100644 index 0000000..fe785b9 --- /dev/null +++ b/lib/src/fota/repository/beta_image_repository.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../model/firmware_update_request.dart'; + +class BetaFirmwareImageRepository { + static const _org = 'OpenEarable'; + static const _repo = 'open-earable-2'; + static const _prereleaseTag = 'pr-builds'; + + Future> getFirmwareImages() async { + try { + final response = await http.get( + Uri.parse( + 'https://api.github.com/repos/$_org/$_repo/releases/tags/$_prereleaseTag', + ), + headers: const { + 'Accept': 'application/vnd.github.v3+json', + }, + ); + + if (response.statusCode != 200) { + return []; + } + + final releaseJson = jsonDecode(response.body) as Map; + + final assets = + (releaseJson['assets'] as List).cast>(); + + final fotaAssets = assets.where((asset) { + final name = asset['name'] as String? ?? ''; + return name.endsWith('fota.zip'); + }); + + final Map> prMap = {}; + + for (final asset in fotaAssets) { + final name = asset['name'] as String; + final match = RegExp( + r'^pr-(\d+)-(.+?)-openearable_v2_fota\.zip$', + ).firstMatch(name); + + if (match != null) { + final prNumber = int.parse(match.group(1)!); + final title = match.group(2)!.replaceAll('_', ' '); + + prMap[prNumber] = { + 'asset': asset, + 'title': title, + }; + } + } + + final result = prMap.entries.map((entry) { + final prNumber = entry.key; + final asset = entry.value['asset'] as Map; + final title = entry.value['title'] as String; + + return RemoteFirmware( + name: title, + version: 'PR #$prNumber', + url: asset['browser_download_url'] as String, + type: FirmwareType.multiImage, + ); + }).toList(); + + result.sort((a, b) { + final aNum = + int.tryParse(a.version.replaceAll(RegExp(r'[^\d]'), '')) ?? 0; + final bNum = + int.tryParse(b.version.replaceAll(RegExp(r'[^\d]'), '')) ?? 0; + return bNum.compareTo(aNum); + }); + + return result; + } catch (e) { + print('Error fetching beta firmwares: $e'); + return []; + } + } +} diff --git a/lib/src/fota/repository/unified_firmware_image_repository.dart b/lib/src/fota/repository/unified_firmware_image_repository.dart index a00f22a..b40c0ba 100644 --- a/lib/src/fota/repository/unified_firmware_image_repository.dart +++ b/lib/src/fota/repository/unified_firmware_image_repository.dart @@ -1,10 +1,10 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/fota/repository/beta_image_repository.dart'; class UnifiedFirmwareRepository { final FirmwareImageRepository _stableRepository = FirmwareImageRepository(); + final BetaFirmwareImageRepository _betaRepository = + BetaFirmwareImageRepository(); List? _cachedStable; List? _cachedBeta; @@ -12,7 +12,11 @@ class UnifiedFirmwareRepository { static const _cacheDuration = Duration(minutes: 15); - /// Fetch stable releases + bool _isCacheExpired() { + if (_lastFetchTime == null) return true; + return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; + } + Future> getStableFirmwares({ bool forceRefresh = false, }) async { @@ -29,27 +33,11 @@ class UnifiedFirmwareRepository { ), ) .toList(); + _lastFetchTime = DateTime.now(); return _cachedStable!; } - bool _isCacheExpired() { - if (_lastFetchTime == null) return true; - return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; - } - - /// Fetch all firmwares (stable + beta) - Future> getAllFirmwares({ - bool includeBeta = false, - }) async { - final stable = await getStableFirmwares(); - if (!includeBeta) { - return stable; - } - final beta = await getBetaFirmwares(); - return [...stable, ...beta]; - } - Future> getBetaFirmwares({ bool forceRefresh = false, }) async { @@ -57,116 +45,28 @@ class UnifiedFirmwareRepository { return _cachedBeta!; } - const org = 'OpenEarable'; - - const repo = 'open-earable-2'; - const prereleaseTag = 'pr-builds'; - - try { - final releaseResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$org/$repo/releases/tags/$prereleaseTag', - ), - headers: { - 'Accept': 'application/vnd.github.v3+json', - }, - ); - - if (releaseResponse.statusCode != 200) { - return []; - } - - final releaseJson = - jsonDecode(releaseResponse.body) as Map; - final assets = - (releaseJson['assets'] as List).cast>(); - - final fotaAssets = assets.where((asset) { - final name = asset['name'] as String? ?? ''; - return name.endsWith('fota.zip'); - }).toList(); - - final Map> prMap = {}; - - for (final asset in fotaAssets) { - final name = asset['name'] as String; - final match = RegExp(r'^pr-(\d+)-(.+?)-openearable_v2_fota\.zip$') - .firstMatch(name); - - if (match != null) { - final prNumber = int.parse(match.group(1)!); - final prTitle = match.group(2)!.replaceAll('_', ' '); - - prMap[prNumber] = { - 'asset': asset, - 'title': prTitle, - 'prNumber': prNumber, - }; - } - } - - final result = []; - - for (final entry in prMap.entries) { - final prNumber = entry.key; - final assetData = entry.value; - - //Commented out because GitHub API rate limits are exceeded quickly - /* - final prResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$owner/$repo/pulls/$prNumber'), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ); - - if (prResponse.statusCode == 200) { - final prJson = jsonDecode(prResponse.body) as Map; - final prTitle = prJson['title'] as String; - final prSha = prJson['head']['sha'] as String; - final asset = assetData['asset'] as Map; - - result.add(FirmwareEntry( - firmware: RemoteFirmware( - name: prTitle, - url: asset['browser_download_url'] as String, - version: prSha.substring(0, 7), - type: FirmwareType.multiImage, - ), - source: FirmwareSource.beta, - )); - } else { - */ - final asset = assetData['asset'] as Map; - result.add( - FirmwareEntry( - firmware: RemoteFirmware( - name: assetData['title'] - as String, // Already sanitized from filename - url: asset['browser_download_url'] as String, - version: 'PR #$prNumber', - type: FirmwareType.multiImage, - ), + final firmwares = await _betaRepository.getFirmwareImages(); + _cachedBeta = firmwares + .map( + (fw) => FirmwareEntry( + firmware: fw, source: FirmwareSource.beta, ), - ); - } - - result.sort((a, b) { - final aNum = - int.tryParse(a.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? - 0; - final bNum = - int.tryParse(b.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? - 0; - return bNum.compareTo(aNum); - }); - - _cachedBeta = result; - return _cachedBeta!; - } catch (e) { - print('Error fetching beta firmwares: $e'); - return []; - } + ) + .toList(); + + _lastFetchTime = DateTime.now(); + return _cachedBeta!; + } + + Future> getAllFirmwares({ + bool includeBeta = false, + }) async { + final stable = await getStableFirmwares(); + if (!includeBeta) return stable; + + final beta = await getBetaFirmwares(); + return [...stable, ...beta]; } void clearCache() {