Skip to content

Commit a469a5c

Browse files
committed
chore: Improved the logo picker dialog.
1 parent f0461fb commit a469a5c

2 files changed

Lines changed: 183 additions & 9 deletions

File tree

lib/widgets/dialog/logo_search/sources.dart

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,27 @@ import 'dart:async';
22
import 'dart:convert';
33
import 'dart:io';
44

5+
import 'package:html/dom.dart' as html;
6+
import 'package:html/parser.dart' as html_parser;
57
import 'package:http/http.dart' as http;
68
import 'package:open_authenticator/app.dart';
79

810
/// A logo search source.
911
mixin Source {
1012
/// All logo sources.
1113
static const List<Source> sources = [
14+
DirectSource(),
1215
BrandfetchSource(),
1316
LogoDevSource(),
1417
UpLeadSource(),
1518
WikimediaSource(),
1619
];
1720

21+
/// Headers to use when querying third-party services.
22+
static const Map<String, String> defaultHeaders = {
23+
HttpHeaders.userAgentHeader: 'OpenAuthenticator logo search (${App.githubRepositoryUrl})',
24+
};
25+
1826
/// The source name (to display attributions).
1927
String get name;
2028

@@ -24,7 +32,7 @@ mixin Source {
2432
/// Check whether the given [imageUrl] is good to display.
2533
static Future<bool> check(http.Client client, Uri imageUrl) async {
2634
try {
27-
http.Response response = await client.head(imageUrl);
35+
http.Response response = await client.head(imageUrl, headers: defaultHeaders);
2836
return response.statusCode == HttpStatus.ok;
2937
} catch (_) {
3038
return false;
@@ -48,6 +56,144 @@ mixin DirectApiSource on Source {
4856
};
4957
}
5058

59+
/// Search directly on the target website.
60+
class DirectSource with Source {
61+
/// Creates a new direct source instance.
62+
const DirectSource();
63+
64+
/// The meta image attributes to look for.
65+
static const List<String> _kMetaImageAttributes = [
66+
'og:logo',
67+
'og:image:secure_url',
68+
'og:image:url',
69+
'og:image',
70+
'twitter:image:src',
71+
'twitter:image',
72+
'image',
73+
'thumbnail',
74+
'msapplication-tileimage',
75+
];
76+
77+
/// The link image relations to look for.
78+
static const List<String> _kLinkImageRelations = [
79+
'apple-touch-icon',
80+
'apple-touch-icon-precomposed',
81+
'icon',
82+
'shortcut icon',
83+
'mask-icon',
84+
];
85+
86+
@override
87+
String get name => 'Direct';
88+
89+
@override
90+
Future<List<Uri>> _trySearch(http.Client client, String userKeywords) async {
91+
Set<Uri> result = {};
92+
for (Uri websiteUrl in _buildWebsiteUrls(userKeywords)) {
93+
try {
94+
http.Response response = await client.get(websiteUrl, headers: Source.defaultHeaders);
95+
if (response.statusCode != HttpStatus.ok || !_isHtml(response)) {
96+
continue;
97+
}
98+
result.addAll(_parseImageUrls(response.body, response.request?.url ?? websiteUrl));
99+
if (result.isNotEmpty) {
100+
break;
101+
}
102+
} catch (_) {}
103+
}
104+
return result.toList();
105+
}
106+
107+
/// Builds the website URLs to try.
108+
Iterable<Uri> _buildWebsiteUrls(String keywords) sync* {
109+
String trimmed = keywords.trim();
110+
if (trimmed.isEmpty) {
111+
return;
112+
}
113+
114+
Uri? directUri = Uri.tryParse(trimmed);
115+
if (directUri?.hasScheme == true && directUri?.hasAuthority == true) {
116+
yield directUri!;
117+
return;
118+
}
119+
120+
String hostLikeKeywords = trimmed.replaceFirst(RegExp(r'^www\.', caseSensitive: false), '');
121+
if (!hostLikeKeywords.contains(' ') && hostLikeKeywords.contains('.')) {
122+
yield Uri.https(hostLikeKeywords, '');
123+
yield Uri.https('www.$hostLikeKeywords', '');
124+
return;
125+
}
126+
127+
String compactKeywords = trimmed.replaceAll(RegExp(r'\s+'), '');
128+
if (compactKeywords.isNotEmpty) {
129+
yield Uri.https('$compactKeywords.com', '');
130+
yield Uri.https('www.$compactKeywords.com', '');
131+
}
132+
}
133+
134+
/// Extracts image URLs from the HTML meta and link tags.
135+
Iterable<Uri> _parseImageUrls(String responseBody, Uri websiteUrl) {
136+
Map<String, Uri> metaImages = {};
137+
Map<String, Uri> linkImages = {};
138+
html.Document document = html_parser.parse(responseBody);
139+
140+
List<html.Element> elements = document.querySelectorAll('meta, link');
141+
for (html.Element element in elements) {
142+
String? content = element.attributes['content'];
143+
String? href = element.attributes['href'];
144+
145+
for (String key in [
146+
element.attributes['property'],
147+
element.attributes['name'],
148+
element.attributes['itemprop'],
149+
].nonNulls) {
150+
if (content == null) {
151+
continue;
152+
}
153+
Uri? imageUrl = _buildImageUrl(content, websiteUrl);
154+
if (imageUrl != null) {
155+
metaImages.putIfAbsent(key.toLowerCase(), () => imageUrl);
156+
}
157+
}
158+
159+
if (href != null) {
160+
Set<String> relations = (element.attributes['rel'] ?? '').toLowerCase().split(RegExp(r'\s+')).where((relation) => relation.isNotEmpty).toSet();
161+
for (String relation in _kLinkImageRelations) {
162+
if (relations.containsAll(relation.split(' '))) {
163+
Uri? imageUrl = _buildImageUrl(href, websiteUrl);
164+
if (imageUrl != null) {
165+
linkImages.putIfAbsent(relation, () => imageUrl);
166+
}
167+
}
168+
}
169+
}
170+
}
171+
172+
return [
173+
for (String attribute in _kMetaImageAttributes)
174+
if (metaImages[attribute] != null) metaImages[attribute]!,
175+
for (String relation in _kLinkImageRelations)
176+
if (linkImages[relation] != null) linkImages[relation]!,
177+
];
178+
}
179+
180+
/// Checks whether the response is HTML.
181+
bool _isHtml(http.Response response) {
182+
String contentType = response.headers[HttpHeaders.contentTypeHeader] ?? '';
183+
return contentType.isEmpty || contentType.toLowerCase().contains('text/html');
184+
}
185+
186+
/// Builds the image URL.
187+
Uri? _buildImageUrl(String imageUrl, Uri websiteUrl) {
188+
try {
189+
Uri uri = websiteUrl.resolve(imageUrl.trim());
190+
return uri.isScheme('http') || uri.isScheme('https') ? uri : null;
191+
} catch (_) {
192+
return null;
193+
}
194+
}
195+
}
196+
51197
/// Search using Wikimedia.
52198
class WikimediaSource with Source {
53199
/// Creates a new Wikimedia source instance.
@@ -60,7 +206,7 @@ class WikimediaSource with Source {
60206
Future<List<Uri>> _trySearch(http.Client client, String userKeywords) async {
61207
List<String> result = [];
62208
try {
63-
http.Response response = await client.get(buildEndpointUrl(userKeywords));
209+
http.Response response = await client.get(buildEndpointUrl(userKeywords), headers: Source.defaultHeaders);
64210
Map<String, dynamic> json = jsonDecode(response.body);
65211
dynamic query = json['query'];
66212
if (query is Map<String, dynamic>) {

lib/widgets/dialog/logo_search/widget.dart

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class _LogoSearchState extends State<LogoSearch> {
5858
/// All searches triggered by the user.
5959
final Map<String, List<String>> searches = {};
6060

61+
/// The images that failed to render.
62+
final Set<String> imageErrors = {};
63+
6164
@override
6265
void initState() {
6366
super.initState();
@@ -68,7 +71,7 @@ class _LogoSearchState extends State<LogoSearch> {
6871

6972
@override
7073
Widget build(BuildContext context) => Column(
71-
mainAxisSize: MainAxisSize.min,
74+
mainAxisSize: .min,
7275
children: [
7376
FTextFormField(
7477
control: .managed(controller: searchKeywordsController),
@@ -90,17 +93,22 @@ class _LogoSearchState extends State<LogoSearch> {
9093
child: Padding(
9194
padding: const EdgeInsets.only(top: kSpace, bottom: kBigSpace),
9295
child: Text(
93-
translations.logoSearch.credits(sources: Source.sources.map((source) => source.name).join(' / ')),
96+
translations.logoSearch.credits(
97+
sources: [
98+
for (Source source in Source.sources)
99+
if (source is! DirectSource) source.name,
100+
].join(' / '),
101+
),
94102
style: context.theme.typography.xs.copyWith(
95103
color: context.theme.colors.foreground.withValues(alpha: 0.75),
96104
),
97-
textAlign: TextAlign.right,
105+
textAlign: .right,
98106
),
99107
),
100108
),
101109
if (searches[filteredSearchKeywords]?.isNotEmpty == true)
102110
Wrap(
103-
alignment: WrapAlignment.center,
111+
alignment: .center,
104112
spacing: widget.imageWidth / 10,
105113
runSpacing: widget.imageWidth / 10,
106114
children: [
@@ -140,7 +148,7 @@ class _LogoSearchState extends State<LogoSearch> {
140148
List<Uri> logos = await Source.sources.search(client, keywords);
141149
setState(() => searches.putIfAbsent(keywords, () => []));
142150
for (Uri logo in logos) {
143-
if (await Source.check(client, logo) && mounted && searches.containsKey(keywords)) {
151+
if (await Source.check(client, logo) && mounted && searches.containsKey(keywords) && !imageErrors.contains(logo.toString())) {
144152
setState(() => searches[keywords]?.add(logo.toString()));
145153
}
146154
if (!searches.containsKey(keywords)) {
@@ -152,16 +160,36 @@ class _LogoSearchState extends State<LogoSearch> {
152160
}
153161
}
154162

163+
/// Removes an image from the displayed results after a rendering error.
164+
void removeImageError(String imageUrl) {
165+
WidgetsBinding.instance.addPostFrameCallback((_) {
166+
if (!mounted || !imageErrors.add(imageUrl)) {
167+
return;
168+
}
169+
setState(() {
170+
searches.updateAll((_, logos) => logos..remove(imageUrl));
171+
searches.removeWhere((_, logos) => logos.isEmpty);
172+
});
173+
});
174+
}
175+
155176
/// Builds the image widget that corresponds to the [imageUrl].
156177
Widget buildImageWidget(String imageUrl) {
178+
if (imageErrors.contains(imageUrl)) {
179+
return const SizedBox.shrink();
180+
}
181+
157182
Widget image = UnconstrainedBox(
158183
child: SizedBox(
159184
width: widget.imageWidth,
160185
child: ResolvedSmartImage(
161186
source: imageUrl,
162187
height: widget.imageWidth,
163188
width: widget.imageWidth,
164-
errorBuilder: (context) => const SizedBox.shrink(),
189+
errorBuilder: (context) {
190+
removeImageError(imageUrl);
191+
return const SizedBox.shrink();
192+
},
165193
),
166194
),
167195
);
@@ -174,7 +202,7 @@ class _LogoSearchState extends State<LogoSearch> {
174202
left: 0,
175203
child: Text(
176204
imageUrl,
177-
style: const TextStyle(color: Colors.red, fontSize: 6),
205+
style: TextStyle(color: context.theme.colors.destructive, fontSize: 6),
178206
textAlign: TextAlign.left,
179207
),
180208
),

0 commit comments

Comments
 (0)