Skip to content
Open
39 changes: 36 additions & 3 deletions open_wearable/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:open_wearable/models/app_upgrade_coordinator.dart';
import 'package:open_wearable/models/app_upgrade_highlight.dart';
import 'package:open_wearable/models/auto_connect_preferences.dart';
import 'package:open_wearable/models/connector_settings.dart';
import 'package:open_wearable/models/labels/label_sensor.dart';
import 'package:open_wearable/models/log_file_manager.dart';
import 'package:open_wearable/models/fota_post_update_verification.dart';
import 'package:open_wearable/models/permissions_helper.dart';
Expand All @@ -35,6 +36,8 @@ import 'package:open_wearable/widgets/onboarding/permissions_onboarding_page.dar
import 'models/bluetooth_auto_connector.dart';
import 'models/logger.dart';
import 'view_models/app_banner_controller.dart';
import 'view_models/label_provider.dart';
import 'view_models/label_set_provider.dart';
import 'view_models/wearables_provider.dart';

void main() async {
Expand All @@ -54,12 +57,42 @@ void main() async {
ChangeNotifierProvider(
create: (context) => FirmwareUpdateRequestProvider(),
),
ChangeNotifierProxyProvider<WearablesProvider, SensorRecorderProvider>(
ChangeNotifierProvider(
create: (context) => LabelSetProvider(),
),
ChangeNotifierProxyProvider<LabelSetProvider, LabelProvider>(
create: (context) => LabelProvider(null),
update: (context, labelSetProvider, labelProvider) {
labelProvider?.setLabelSet(labelSetProvider.selectedLabelSet);
return labelProvider!;
},
),
ChangeNotifierProxyProvider2<WearablesProvider, LabelSetProvider,
SensorRecorderProvider>(
create: (context) => SensorRecorderProvider(),
update: (context, wearablesProvider, recorderProvider) {
update: (
context,
wearablesProvider,
labelSetProvider,
recorderProvider,
) {
final provider = recorderProvider ?? SensorRecorderProvider();
final labelSet = labelSetProvider.selectedLabelSet;
final wearables = [...wearablesProvider.wearables];
if (labelSet != null) {
final labelProvider = context.read<LabelProvider>();
wearables.add(
LabelWearable(
labelSet: labelSet,
labelStream: labelProvider.activeLabelStream,
),
);
}
logger.t(
'Updating SensorRecorderProvider with label set: $labelSet',
);
provider.synchronizeConnectedWearables(
wearablesProvider.wearables,
wearables,
);
return provider;
},
Expand Down
38 changes: 38 additions & 0 deletions open_wearable/lib/models/app_upgrade_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,44 @@ class AppUpgradeRegistry {
),
],
),
AppUpgradeHighlight(
version: '1.5.0',
eyebrow: 'OpenWearables 1.5.0',
title: 'Add labels while\nyou record',
summary:
'Mark important moments in local recordings and find devices faster.',
heroDescription:
'OpenWearables 1.5.0 brings reusable recording labels to the local recorder. '
'Create label sets, choose one before recording, and tap labels during a session so key events are saved with your sensor data.',
accentColor: Color(0xFF8F6A67),
useHeroGradient: false,
features: <AppUpgradeFeatureHighlight>[
AppUpgradeFeatureHighlight(
icon: Icons.label_rounded,
title: 'Recording labels',
description:
'Create reusable label sets and mark events while recording. Label states are saved alongside sensor data with timestamps for easier review and analysis.',
),
AppUpgradeFeatureHighlight(
icon: Icons.filter_alt_rounded,
title: 'Device name filter',
description:
'Filter the available device list by name, starting with OpenEarable, so nearby Bluetooth devices are easier to scan and connect.',
),
AppUpgradeFeatureHighlight(
icon: Icons.verified_rounded,
title: 'Smarter firmware matching',
description:
'Firmware lists and post-update checks now better recognize installed versions, including beta and pull-request builds.',
),
AppUpgradeFeatureHighlight(
icon: Icons.auto_fix_high_rounded,
title: 'Workflow polish',
description:
'Small fixes make label-set editing, delete confirmations, selected dropdowns, color indicators, and update verification feel more predictable.',
),
],
),
];

/// Returns the configured highlight for [version], if any.
Expand Down
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) ?? '');
}
49 changes: 49 additions & 0 deletions open_wearable/lib/models/firmware_version_matcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
String? normalizeFirmwareVersion(String? value) {
final cleaned = value?.replaceAll('\x00', '').trim();
if (cleaned == null || cleaned.isEmpty) {
return null;
}
return cleaned;
}

bool firmwareVersionsMatch(String? expected, String? actual) {
final normalizedExpected = normalizeFirmwareVersion(expected);
final normalizedActual = normalizeFirmwareVersion(actual);
if (normalizedExpected == null || normalizedActual == null) {
return false;
}

final expectedComparison = _comparisonValue(normalizedExpected);
final actualComparison = _comparisonValue(normalizedActual);
if (expectedComparison == actualComparison) {
return true;
}

final expectedPrNumber = _extractPullRequestNumber(expectedComparison);
final actualPrNumber = _extractPullRequestNumber(actualComparison);
if (expectedPrNumber != null && actualPrNumber != null) {
return expectedPrNumber == actualPrNumber;
}

return actualComparison.contains(expectedComparison) ||
expectedComparison.contains(actualComparison);
}

String _comparisonValue(String value) {
return value.toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
}

String? _extractPullRequestNumber(String value) {
for (final pattern in _pullRequestPatterns) {
final match = pattern.firstMatch(value);
if (match != null) {
return match.group(1);
}
}
return null;
}

final _pullRequestPatterns = <RegExp>[
RegExp(r'(?:^|[^a-z0-9])pr\s*[-#]?\s*(\d+)(?=$|[^a-z0-9])'),
RegExp(r'(?:^|[^a-z0-9])pull\s*request\s*#?\s*(\d+)(?=$|[^a-z0-9])'),
];
20 changes: 5 additions & 15 deletions open_wearable/lib/models/fota_post_update_verification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/models/device_name_formatter.dart';
import 'package:open_wearable/models/firmware_version_matcher.dart';

/// Metadata returned when a post-update verification check is armed.
class ArmedFotaPostUpdateVerification {
Expand Down Expand Up @@ -323,7 +324,7 @@ class FotaPostUpdateVerificationCoordinator {
final version = await firmwareCap
.readDeviceFirmwareVersion()
.timeout(const Duration(seconds: 4));
final normalized = _normalizeVersion(version);
final normalized = normalizeFirmwareVersion(version);
if (normalized != null) {
return normalized;
}
Expand Down Expand Up @@ -450,23 +451,20 @@ class FotaPostUpdateVerificationCoordinator {

String? _extractExpectedFirmwareVersion(SelectedFirmware? firmware) {
if (firmware is RemoteFirmware) {
return _normalizeVersion(firmware.version);
return normalizeFirmwareVersion(firmware.version);
}

if (firmware is LocalFirmware) {
final match =
RegExp(r'(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)').firstMatch(firmware.name);
return _normalizeVersion(match?.group(1));
return normalizeFirmwareVersion(match?.group(1));
}

return null;
}

bool _firmwareVersionsMatch(String expected, String actual) {
if (actual == expected) {
return true;
}
return actual.contains(expected) || expected.contains(actual);
return firmwareVersionsMatch(expected, actual);
}

String _buildMessage({
Expand Down Expand Up @@ -533,14 +531,6 @@ class FotaPostUpdateVerificationCoordinator {
return trimmed.toLowerCase();
}

String? _normalizeVersion(String? value) {
final cleaned = value?.replaceAll('\x00', '').trim();
if (cleaned == null || cleaned.isEmpty) {
return null;
}
return cleaned;
}

String? _normalizeSideLabel(String? sideLabel) {
if (sideLabel == null || sideLabel.isEmpty) {
return null;
Expand Down
Loading