Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions open_wearable/lib/models/beta_firmware_title_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:io';

import 'package:open_earable_flutter/open_earable_flutter.dart';

typedef PullRequestTitleFetcher = Future<Map<int, String>> Function(
Set<int> pullRequestNumbers,
);

class BetaFirmwareTitleResolver {
final PullRequestTitleFetcher _fetchTitles;

BetaFirmwareTitleResolver({
PullRequestTitleFetcher? fetchTitles,
}) : _fetchTitles = fetchTitles ?? fetchGitHubPullRequestTitles;

Future<List<FirmwareEntry>> resolve(List<FirmwareEntry> entries) async {
final pullRequestNumbers = <int>{
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<Map<int, String>> _fetchTitlesSafely(
Set<int> pullRequestNumbers,
) async {
try {
return await _fetchTitles(pullRequestNumbers);
} catch (_) {
return const {};
}
}

FirmwareEntry _entryWithResolvedTitle(
FirmwareEntry entry,
Map<int, String> 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<Map<int, String>> fetchGitHubPullRequestTitles(
Set<int> 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<MapEntry<int, String>?> _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<void>();
return null;
}

final body = await utf8.decodeStream(response);
final json = jsonDecode(body) as Map<String, dynamic>;
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) ?? '');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,6 +19,7 @@ class FirmwareList extends StatefulWidget {
class _FirmwareListState extends State<FirmwareList> {
late Future<List<FirmwareEntry>> _firmwareFuture;
final _repository = UnifiedFirmwareRepository();
final _betaTitleResolver = BetaFirmwareTitleResolver();
String? firmwareVersion;
bool _expanded = false;

Expand Down Expand Up @@ -46,7 +48,9 @@ class _FirmwareListState extends State<FirmwareList> {
}

try {
beta = await _repository.getBetaFirmwares();
beta = await _betaTitleResolver.resolve(
await _repository.getBetaFirmwares(),
);
} catch (error) {
betaError = error;
// Beta feed is optional. Ignore failures.
Expand Down Expand Up @@ -454,7 +458,7 @@ class _FirmwareListState extends State<FirmwareList> {
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,
Expand Down
121 changes: 121 additions & 0 deletions open_wearable/test/models/beta_firmware_title_resolver_test.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}