diff --git a/open_wearable/lib/models/beta_firmware_title_resolver.dart b/open_wearable/lib/models/beta_firmware_title_resolver.dart new file mode 100644 index 00000000..73efcc21 --- /dev/null +++ b/open_wearable/lib/models/beta_firmware_title_resolver.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +typedef PullRequestTitleFetcher = Future> Function( + Set pullRequestNumbers, +); + +class BetaFirmwareTitleResolver { + final PullRequestTitleFetcher _fetchTitles; + + BetaFirmwareTitleResolver({ + PullRequestTitleFetcher? fetchTitles, + }) : _fetchTitles = fetchTitles ?? fetchGitHubPullRequestTitles; + + Future> resolve(List entries) async { + final pullRequestNumbers = { + for (final entry in entries) + if (entry.isBeta) ...[ + if (_pullRequestNumberFor(entry.firmware) case final number?) number, + ], + }; + + if (pullRequestNumbers.isEmpty) { + return entries; + } + + final titles = await _fetchTitlesSafely(pullRequestNumbers); + if (titles.isEmpty) { + return entries; + } + + return [ + for (final entry in entries) _entryWithResolvedTitle(entry, titles), + ]; + } + + Future> _fetchTitlesSafely( + Set pullRequestNumbers, + ) async { + try { + return await _fetchTitles(pullRequestNumbers); + } catch (_) { + return const {}; + } + } + + FirmwareEntry _entryWithResolvedTitle( + FirmwareEntry entry, + Map titles, + ) { + if (!entry.isBeta) { + return entry; + } + + final pullRequestNumber = _pullRequestNumberFor(entry.firmware); + final title = titles[pullRequestNumber]?.trim(); + if (title == null || title.isEmpty || title == entry.firmware.name) { + return entry; + } + + final firmware = entry.firmware; + return FirmwareEntry( + firmware: RemoteFirmware( + name: title, + version: firmware.version, + url: firmware.url, + type: firmware.type, + ), + source: entry.source, + ); + } +} + +Future> fetchGitHubPullRequestTitles( + Set pullRequestNumbers, +) async { + if (pullRequestNumbers.isEmpty) { + return const {}; + } + + final client = HttpClient(); + try { + final entries = await Future.wait( + pullRequestNumbers.map( + (number) => _fetchGitHubPullRequestTitle(client, number), + ), + ); + return { + for (final entry in entries) + if (entry != null) entry.key: entry.value, + }; + } finally { + client.close(force: true); + } +} + +Future?> _fetchGitHubPullRequestTitle( + HttpClient client, + int pullRequestNumber, +) async { + try { + final request = await client.getUrl( + Uri.https( + 'api.github.com', + '/repos/OpenEarable/open-earable-2/pulls/$pullRequestNumber', + ), + ); + request.headers.set( + HttpHeaders.acceptHeader, + 'application/vnd.github+json', + ); + request.headers.set(HttpHeaders.userAgentHeader, 'OpenWearable'); + + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + return null; + } + + final body = await utf8.decodeStream(response); + final json = jsonDecode(body) as Map; + final title = (json['title'] as String?)?.trim(); + if (title == null || title.isEmpty) { + return null; + } + + return MapEntry(pullRequestNumber, title); + } catch (_) { + return null; + } +} + +int? _pullRequestNumberFor(RemoteFirmware firmware) { + return _pullRequestNumberFrom(firmware.version) ?? + _pullRequestNumberFrom(firmware.url) ?? + _pullRequestNumberFrom(firmware.name); +} + +int? _pullRequestNumberFrom(String value) { + final match = RegExp( + r'(?:^|[^a-z0-9])pr\s*[-#]?\s*(\d+)(?=$|[^a-z0-9])', + caseSensitive: false, + ).firstMatch(value); + return int.tryParse(match?.group(1) ?? ''); +} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index 04cd1fde..fcb41681 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/beta_firmware_title_resolver.dart'; import 'package:open_wearable/models/firmware_version_matcher.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; @@ -18,6 +19,7 @@ class FirmwareList extends StatefulWidget { class _FirmwareListState extends State { late Future> _firmwareFuture; final _repository = UnifiedFirmwareRepository(); + final _betaTitleResolver = BetaFirmwareTitleResolver(); String? firmwareVersion; bool _expanded = false; @@ -46,7 +48,9 @@ class _FirmwareListState extends State { } try { - beta = await _repository.getBetaFirmwares(); + beta = await _betaTitleResolver.resolve( + await _repository.getBetaFirmwares(), + ); } catch (error) { betaError = error; // Beta feed is optional. Ignore failures. @@ -454,7 +458,7 @@ class _FirmwareListState extends State { const SizedBox(width: 8), Expanded( child: Text( - 'Beta firmware is experimental and is not recommended to be used. Use at your own risk.', + 'Beta firmware is experimental. Use at your own risk.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w600, diff --git a/open_wearable/test/models/beta_firmware_title_resolver_test.dart b/open_wearable/test/models/beta_firmware_title_resolver_test.dart new file mode 100644 index 00000000..67dc6da2 --- /dev/null +++ b/open_wearable/test/models/beta_firmware_title_resolver_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/beta_firmware_title_resolver.dart'; + +void main() { + group('BetaFirmwareTitleResolver', () { + test('uses GitHub pull request title for beta firmware display name', + () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (numbers) async { + expect(numbers, {250}); + return {250: '2.2.7'}; + }, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: '2 2 7', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, '2.2.7'); + expect(entries.single.firmware.version, 'PR #250'); + expect(entries.single.firmware.url, contains('pr-250-2_2_7')); + expect(entries.single.firmware.type, FirmwareType.multiImage); + expect(entries.single.isBeta, isTrue); + }); + + test('preserves punctuation from pull request titles', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => {228: 'Feature/audio response'}, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: 'Feature audio response', + version: 'PR #228', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-228-Feature_audio_response-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, 'Feature/audio response'); + }); + + test('preserves brackets and dots from pull request titles', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => {250: '[2.2.7] FOTA retry upload'}, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: '2 2 7 FOTA retry upload', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7_FOTA_retry_upload-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, '[2.2.7] FOTA retry upload'); + }); + + test('does not change stable firmware entries', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (numbers) async { + expect(numbers, isEmpty); + return const {}; + }, + ); + final entry = FirmwareEntry( + firmware: RemoteFirmware( + name: 'OpenEarable 2.2.7', + version: '2.2.7', + url: 'https://example.com/openearable_v2_fota.zip', + type: FirmwareType.multiImage, + ), + source: FirmwareSource.stable, + ); + + final entries = await resolver.resolve([entry]); + + expect(entries.single, same(entry)); + }); + + test('keeps asset-derived title when pull request title is unavailable', + () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => const {}, + ); + final entry = _betaEntry( + name: '2 2 7', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7-openearable_v2_fota.zip', + ); + + final entries = await resolver.resolve([entry]); + + expect(entries.single, same(entry)); + }); + }); +} + +FirmwareEntry _betaEntry({ + required String name, + required String version, + required String url, +}) { + return FirmwareEntry( + firmware: RemoteFirmware( + name: name, + version: version, + url: url, + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + ); +}