This commit is contained in:
Adam Velebil 2023-02-28 17:24:21 +01:00
commit 64e2d1bc51
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
17 changed files with 987 additions and 25 deletions

View File

@ -0,0 +1,22 @@
== Custom Account Icons
Yubico Authenticator supports adding a custom icon pack to use for your OATH
(TOTP/HOTP) accounts, making the list a bit easier to navigate. Icon packs must
be in the
https://github.com/beemdevelopment/Aegis/blob/master/docs/iconpacks.md[Aegis Icon Pack format].
You can use one of the existing pre-built packages from
https://aegis-icons.github.io/ or
https://github.com/alexbakker/aegis-simple-icons, or you can create your own.
=== Installing an icon pack
Once you have an icon pack on your computer or Android device and wish to use
it, follow these steps:
1. Open the Yubico Authenticator app and insert or tap your OATH-enabled YubiKey.
2. Make sure the `Authenticator` section is chosen, where you can see your list of accounts.
3. Press the `Configure YubiKey` button, and select `Custom icons` from the menu.
4. Press the `Load icon pack` button, select the icon pack zip-file, and wait for it to load.
Once done, any account that has an issuer that is supported by the icon pack
will display the custom icon instead of the standard colored circle with a
letter. You can also use the `Custom icons` dialog to replace the icon pack, or
to remove it.

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -163,8 +163,7 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
_sub = _events.receiveBroadcastStream().listen((event) {
final json = jsonDecode(event);
List<OathPair>? newState = json != null
? List.unmodifiable(
(json as List).map((e) => OathPair.fromJson(e)).toList())
? List.from((json as List).map((e) => OathPair.fromJson(e)).toList())
: null;
if (state != null && newState == null) {
// If we go from non-null to null this means we should stop listening to

View File

@ -59,6 +59,7 @@ class MainPage extends ConsumerWidget {
'licenses',
'user_interaction_prompt',
'oath_add_account',
'oath_icon_pack_dialog',
].contains(route.settings.name);
});
});

View File

@ -83,12 +83,34 @@
"capacity": {}
}
},
"oath_custom_icons": "Custom icons",
"oath_custom_icons_description": "Icon packs can make your accounts more easily distinguishable with familiar logos and colors.",
"oath_custom_icons_replace": "Replace icon pack",
"oath_custom_icons_loading": "Loading icon pack\u2026",
"oath_custom_icons_load": "Load icon pack",
"oath_custom_icons_remove": "Remove icon pack",
"oath_custom_icons_icon_pack_removed": "Icon pack removed",
"oath_custom_icons_err_icon_pack_remove": "Error removing icon pack",
"oath_custom_icons_choose_icon_pack": "Choose icon pack",
"oath_custom_icons_icon_pack_imported": "Icon pack imported",
"oath_custom_icons_err_icon_pack_import": "Error importing icon pack: {message}",
"@oath_custom_icons_err_icon_pack_import": {
"placeholders": {
"message": {}
}
},
"oath_custom_icons_learn_more": "Learn\u00a0more",
"oath_custom_icons_err_import_general": "Import error",
"oath_custom_icons_err_file_not_found": "File not found",
"oath_custom_icons_err_file_too_big": "File size too big",
"oath_custom_icons_err_invalid_icon_pack": "Invalid icon pack",
"oath_custom_icons_err_filesystem_error": "File system operation error",
"widgets_cancel": "Cancel",
"widgets_close": "Close",
"mgmt_min_one_interface": "At least one interface must be enabled",
"mgmt_reconfiguring_yubikey": "Reconfiguring YubiKey...",
"mgmt_reconfiguring_yubikey": "Reconfiguring YubiKey\u2026",
"mgmt_configuration_updated": "Configuration updated",
"mgmt_configuration_updated_remove_reinsert": "Configuration updated, remove and reinsert your YubiKey",
"mgmt_toggle_applications": "Toggle applications",
@ -125,7 +147,7 @@
"general_quit": "Quit",
"fido_press_fingerprint_begin": "Press your finger against the YubiKey to begin.",
"fido_keep_touching_yubikey": "Keep touching your YubiKey repeatedly...",
"fido_keep_touching_yubikey": "Keep touching your YubiKey repeatedly\u2026",
"fido_fingerprint_captured": "Fingerprint captured successfully!",
"fido_fingerprint_added": "Fingerprint added",
"fido_error_setting_name": "Error setting name",
@ -212,7 +234,7 @@
"fido_place_back_on_reader": "Place your YubiKey back on the reader",
"fido_reinsert_yubikey": "Re-insert your YubiKey",
"fido_touch_yubikey": "Touch your YubiKey now",
"fido_press_reset": "Press reset to begin...",
"fido_press_reset": "Press reset to begin\u2026",
"fido_factory_reset": "Factory reset",
"fido_factory_reset_description": "Factory reset this application",
"fido_fido_app_reset": "FIDO application reset",
@ -240,7 +262,7 @@
"appFailurePage_btn_unlock": "Unlock",
"appFailurePage_txt_info": "WebAuthn management requires elevated privileges.",
"appFailurePage_msg_permission": "Elevating permissions...",
"appFailurePage_msg_permission": "Elevating permissions\u2026",
"mainDrawer_txt_applications": "Toggle applications",
"mainDrawer_txt_settings": "Settings",

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:yubico_authenticator/app/logging.dart';
final _log = Logger('icon_cache');
class IconCacheFs {
Future<ByteData?> read(String fileName) async {
final file = await _getFile(fileName);
final exists = await file.exists();
if (exists) {
_log.debug('File $fileName exists in cache');
} else {
_log.debug('File $fileName does not exist in cache');
}
return exists ? (await file.readAsBytes()).buffer.asByteData() : null;
}
Future<void> write(String fileName, Uint8List data) async {
_log.debug('Writing $fileName to cache');
final file = await _getFile(fileName);
if (!await file.exists()) {
await file.create(recursive: true, exclusive: false);
}
await file.writeAsBytes(data, flush: true);
}
Future<void> clear() async {
final cacheDirectory = await _cacheDirectory;
if (await cacheDirectory.exists()) {
try {
await cacheDirectory.delete(recursive: true);
} catch (e) {
_log.error(
'Failed to delete cache directory ${cacheDirectory.path}', e);
}
}
}
String _buildCacheDirectoryPath(String supportDirectory) =>
join(supportDirectory, 'issuer_icons_cache');
Future<Directory> get _cacheDirectory async {
final supportDirectory = await getApplicationSupportDirectory();
return Directory(_buildCacheDirectoryPath(supportDirectory.path));
}
Future<File> _getFile(String fileName) async {
final supportDirectory = await getApplicationSupportDirectory();
final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path);
return File(
join(cacheDirectoryPath, '${basenameWithoutExtension(fileName)}.dat'));
}
}
class IconCacheMem {
final _cache = <String, ByteData>{};
ByteData? read(String fileName) {
return _cache[fileName];
}
void write(String fileName, Uint8List data) {
_cache.putIfAbsent(fileName, () => data.buffer.asByteData());
}
void clear() async {
_cache.clear();
}
}
class IconCache {
final IconCacheMem memCache;
final IconCacheFs fsCache;
const IconCache(this.memCache, this.fsCache);
}
final iconCacheProvider =
Provider<IconCache>((ref) => IconCache(IconCacheMem(), IconCacheFs()));

View File

@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:vector_graphics/vector_graphics.dart';
import 'package:vector_graphics_compiler/vector_graphics_compiler.dart';
import 'package:yubico_authenticator/app/logging.dart';
import 'icon_cache.dart';
final _log = Logger('icon_file_loader');
class IconFileLoader extends BytesLoader {
final WidgetRef _ref;
final File _file;
const IconFileLoader(this._ref, this._file);
@override
Future<ByteData> loadBytes(BuildContext? context) async {
final cacheFileName = basename(_file.path);
final memCache = _ref.read(iconCacheProvider).memCache;
// check if the requested file exists in memory cache
var cachedData = memCache.read(cacheFileName);
if (cachedData != null) {
_log.debug('Returning $cacheFileName image data from memory cache');
return cachedData;
}
final fsCache = _ref.read(iconCacheProvider).fsCache;
// check if the requested file exists in fs cache
cachedData = await fsCache.read(cacheFileName);
if (cachedData != null) {
memCache.write(cacheFileName, cachedData.buffer.asUint8List());
_log.debug('Returning $cacheFileName image data from fs cache');
return cachedData;
}
final decodedData = await compute((File file) async {
final fileData = await file.readAsString();
final TimelineTask task = TimelineTask()..start('encodeSvg');
final Uint8List compiledBytes = encodeSvg(
xml: fileData,
debugName: file.path,
enableClippingOptimizer: false,
enableMaskingOptimizer: false,
enableOverdrawOptimizer: false,
);
task.finish();
// for testing try: await Future.delayed(const Duration(seconds: 5));
return compiledBytes;
}, _file, debugLabel: 'Process SVG data');
memCache.write(cacheFileName, decodedData);
await fsCache.write(cacheFileName, decodedData);
return decodedData.buffer.asByteData();
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart';
String getLocalIconFileName(String iconPackFileName) {
final sha = sha256.convert(utf8.encode(iconPackFileName)).toString();
return sha.substring(0, sha.length ~/ 2) + extension(iconPackFileName);
}
class IconPackIcon {
final String filename;
final String? category;
final List<String> issuer;
const IconPackIcon({
required this.filename,
required this.category,
required this.issuer,
});
}
class IconPack {
final String uuid;
final String name;
final int version;
final Directory directory;
final List<IconPackIcon> icons;
const IconPack({
required this.uuid,
required this.name,
required this.version,
required this.directory,
required this.icons,
});
File? getFileForIssuer(String? issuer) {
if (issuer == null) {
return null;
}
final matching = icons.where((element) =>
element.issuer.any((element) => element == issuer.toUpperCase()));
final issuerImageFile = matching.isNotEmpty
? File(join(directory.path, getLocalIconFileName(matching.first.filename)))
: null;
if (issuerImageFile != null && !issuerImageFile.existsSync()) {
return null;
}
return issuerImageFile;
}
}

View File

@ -0,0 +1,203 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:yubico_authenticator/app/message.dart';
import 'package:yubico_authenticator/app/state.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart';
import 'package:yubico_authenticator/widgets/responsive_dialog.dart';
class IconPackDialog extends ConsumerWidget {
const IconPackDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final iconPack = ref.watch(iconPackProvider);
return ResponsiveDialog(
title: Text(l10n.oath_custom_icons),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DialogDescription(),
const SizedBox(height: 4),
_action(iconPack, l10n),
_loadedIconPackRow(iconPack),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
Widget? _loadedIconPackRow(AsyncValue<IconPack?> iconPack) {
return iconPack.when(
data: (IconPack? data) =>
(data != null) ? _IconPackDescription(data) : null,
error: (Object error, StackTrace stackTrace) => null,
loading: () => null);
}
Widget? _action(AsyncValue<IconPack?> iconPack, AppLocalizations l10n) =>
iconPack.when(
data: (IconPack? data) => _ImportActionChip(data != null
? l10n.oath_custom_icons_replace
: l10n.oath_custom_icons_load),
error: (Object error, StackTrace stackTrace) =>
_ImportActionChip(l10n.oath_custom_icons_load),
loading: () => _ImportActionChip(
l10n.oath_custom_icons_loading,
avatar: const CircularProgressIndicator(),
));
}
class _DialogDescription extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return RichText(
text: TextSpan(
text: l10n.oath_custom_icons_description,
style: theme.textTheme.bodyMedium,
children: [const TextSpan(text: ' '), _createLearnMoreLink(context)],
),
);
}
Uri get _learnMoreUri =>
Uri.parse('https://github.com/Yubico/yubioath-flutter/blob/'
'main/doc/Custom_Account_Icons.adoc');
TextSpan _createLearnMoreLink(BuildContext context) {
final theme = Theme.of(context);
return TextSpan(
text: AppLocalizations.of(context)!.oath_custom_icons_learn_more,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.primary),
recognizer: TapGestureRecognizer()
..onTap = () async {
await launchUrl(_learnMoreUri, mode: LaunchMode.externalApplication);
},
children: const [
TextSpan(text: ' ') // without this the recognizer takes over whole row
],
);
}
}
class _IconPackDescription extends ConsumerWidget {
final IconPack iconPack;
const _IconPackDescription(this.iconPack);
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
fit: FlexFit.loose,
child: RichText(
text: TextSpan(
text: iconPack.name,
style: theme.textTheme.bodyMedium,
children: [TextSpan(text: ' (${iconPack.version})')]))),
Row(
children: [
IconButton(
tooltip: l10n.oath_custom_icons_remove,
onPressed: () async {
final removePackStatus =
await ref.read(iconPackProvider.notifier).removePack();
await ref.read(withContextProvider)(
(context) async {
if (removePackStatus) {
showMessage(context,
l10n.oath_custom_icons_icon_pack_removed);
} else {
showMessage(context,
l10n.oath_custom_icons_err_icon_pack_remove);
}
},
);
},
icon: const Icon(Icons.delete_outline)),
],
)
]);
}
}
class _ImportActionChip extends ConsumerWidget {
final String _label;
final Widget avatar;
const _ImportActionChip(this._label,
{this.avatar = const Icon(Icons.download_outlined)});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ActionChip(
onPressed: () async {
_importAction(context, ref);
},
avatar: avatar,
label: Text(_label));
}
void _importAction(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context)!;
final result = await FilePicker.platform.pickFiles(
allowedExtensions: ['zip'],
type: FileType.custom,
allowMultiple: false,
lockParentWindow: true,
dialogTitle: l10n.oath_custom_icons_choose_icon_pack);
if (result != null && result.files.isNotEmpty) {
final importStatus = await ref
.read(iconPackProvider.notifier)
.importPack(l10n, result.paths.first!);
await ref.read(withContextProvider)((context) async {
if (importStatus) {
showMessage(context, l10n.oath_custom_icons_icon_pack_imported);
} else {
showMessage(
context,
l10n.oath_custom_icons_err_icon_pack_import(
ref.read(iconPackProvider.notifier).lastError ??
l10n.oath_custom_icons_err_import_general));
}
});
}
}
}

View File

@ -0,0 +1,207 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:yubico_authenticator/app/logging.dart';
import 'icon_cache.dart';
import 'icon_pack.dart';
final _log = Logger('icon_pack_manager');
class IconPackManager extends StateNotifier<AsyncValue<IconPack?>> {
final IconCache _iconCache;
String? _lastError;
final _packSubDir = 'issuer_icons';
IconPackManager(this._iconCache) : super(const AsyncValue.data(null)) {
readPack();
}
String? get lastError => _lastError;
void readPack() async {
final packDirectory = await _packDirectory;
final packFile =
File(join(packDirectory.path, getLocalIconFileName('pack.json')));
_log.debug('Looking for file: ${packFile.path}');
if (!await packFile.exists()) {
_log.debug('Failed to find icons pack ${packFile.path}');
state = AsyncValue.error(
'Failed to find icon pack ${packFile.path}', StackTrace.current);
return;
}
try {
var packContent = await packFile.readAsString();
Map<String, dynamic> pack = const JsonDecoder().convert(packContent);
final icons = List<IconPackIcon>.from(pack['icons'].map((icon) =>
IconPackIcon(
filename: icon['filename'],
category: icon['category'],
issuer: List<String>.from(icon['issuer'])
.map((e) => e.toUpperCase())
.toList(growable: false))));
state = AsyncValue.data(IconPack(
uuid: pack['uuid'],
name: pack['name'],
version: pack['version'],
directory: packDirectory,
icons: icons));
_log.debug(
'Parsed ${state.value?.name} with ${state.value?.icons.length} icons');
} catch (e) {
_log.debug('Failed to parse icons pack ${packFile.path}');
state = AsyncValue.error(
'Failed to parse icon pack ${packFile.path}', StackTrace.current);
return;
}
}
Future<bool> importPack(AppLocalizations l10n, String filePath) async {
// remove existing pack first
await removePack();
final packFile = File(filePath);
state = const AsyncValue.loading();
if (!await packFile.exists()) {
_log.error('Input file does not exist');
_lastError = l10n.oath_custom_icons_err_file_not_found;
state = AsyncValue.error('Input file does not exist', StackTrace.current);
return false;
}
if (await packFile.length() > 5 * 1024 * 1024) {
_log.error('File size too big.');
_lastError = l10n.oath_custom_icons_err_file_too_big;
state = AsyncValue.error('File size too big', StackTrace.current);
return false;
}
// copy input file to temporary folder
final tempDirectory = await Directory.systemTemp.createTemp('yubioath');
final tempCopy =
await packFile.copy(join(tempDirectory.path, basename(packFile.path)));
final bytes = await File(tempCopy.path).readAsBytes();
final unpackDirectory = Directory(join(tempDirectory.path, 'unpack'));
final archive = ZipDecoder().decodeBytes(bytes, verify: true);
for (final file in archive) {
final filename = file.name;
if (file.size > 0) {
final data = file.content as List<int>;
final extractedFile =
File(join(unpackDirectory.path, getLocalIconFileName(filename)));
_log.debug('Writing file: ${extractedFile.path} (size: ${file.size})');
final createdFile = await extractedFile.create(recursive: true);
await createdFile.writeAsBytes(data);
}
}
// check that there is pack.json
final packJsonFile =
File(join(unpackDirectory.path, getLocalIconFileName('pack.json')));
if (!await packJsonFile.exists()) {
_log.error('File is not an icon pack: missing pack.json');
_lastError = l10n.oath_custom_icons_err_invalid_icon_pack;
state = AsyncValue.error('File is not an icon pack', StackTrace.current);
await _deleteDirectory(tempDirectory);
return false;
}
// test pack.json
try {
var packContent = await packJsonFile.readAsString();
const JsonDecoder().convert(packContent);
} catch (e) {
_log.error('Failed to parse pack.json: $e');
_lastError = l10n.oath_custom_icons_err_invalid_icon_pack;
state = AsyncValue.error('File is not an icon pack', StackTrace.current);
await _deleteDirectory(tempDirectory);
return false;
}
// remove old icons pack and icon pack cache
final packDirectory = await _packDirectory;
if (!await _deleteDirectory(packDirectory)) {
_log.error('Failure when deleting original pack directory');
_lastError = l10n.oath_custom_icons_err_filesystem_error;
state = AsyncValue.error(
'Failure deleting original pack directory', StackTrace.current);
await _deleteDirectory(tempDirectory);
return false;
}
await _iconCache.fsCache.clear();
_iconCache.memCache.clear();
// moves unpacked files to the directory final directory
await unpackDirectory.rename(packDirectory.path);
readPack();
await _deleteDirectory(tempDirectory);
return true;
}
/// removes imported icon pack
Future<bool> removePack() async {
_iconCache.memCache.clear();
await _iconCache.fsCache.clear();
final cleanupStatus = await _deleteDirectory(await _packDirectory);
state = const AsyncValue.data(null);
return cleanupStatus;
}
Future<bool> _deleteDirectory(Directory directory) async {
if (await directory.exists()) {
await directory.delete(recursive: true);
}
if (await directory.exists()) {
_log.error('Failed to delete directory');
return false;
}
return true;
}
Future<Directory> get _packDirectory async {
final supportDirectory = await getApplicationSupportDirectory();
return Directory(join(supportDirectory.path, _packSubDir));
}
}
final iconPackProvider =
StateNotifierProvider<IconPackManager, AsyncValue<IconPack?>>(
(ref) => IconPackManager(ref.watch(iconCacheProvider)));

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -22,6 +22,8 @@ const setOrManagePasswordAction = Key('$_prefix.set_or_manage_oath_password');
const addAccountAction = Key('$_prefix.add_account');
const resetAction = Key('$_prefix.reset');
const customIconsAction = Key('$_prefix.custom_icons');
const noAccountsView = Key('$_prefix.no_accounts');
// This is global so we can access it from the global Ctrl+F shortcut.

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart';
import 'package:vector_graphics/vector_graphics.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_file_loader.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart';
import 'package:yubico_authenticator/widgets/delayed_visibility.dart';
class AccountIcon extends ConsumerWidget {
final String? issuer;
final Widget defaultWidget;
static const double _width = 40;
static const double _height = 40;
const AccountIcon({
super.key,
required this.issuer,
required this.defaultWidget,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final iconPack = ref.watch(iconPackProvider);
return iconPack.when(
data: (IconPack? iconPack) {
final issuerImageFile = iconPack?.getFileForIssuer(issuer);
if (issuerImageFile == null) {
return defaultWidget;
}
switch (extension(issuerImageFile.path)) {
case '.svg':
{
return _decodeSvg(ref, issuerImageFile);
}
case '.png':
case '.jpg':
{
return _decodeRasterImage(ref, issuerImageFile);
}
}
return defaultWidget;
},
error: (_, __) => defaultWidget,
loading: () => defaultWidget);
}
Widget _decodeSvg(WidgetRef ref, File file) {
return VectorGraphic(
width: _width,
height: _height,
fit: BoxFit.fill,
loader: IconFileLoader(ref, file),
placeholderBuilder: (BuildContext _) {
return DelayedVisibility(
delay: const Duration(milliseconds: 10),
child: Stack(alignment: Alignment.center, children: [
Opacity(
opacity: 0.5,
child: defaultWidget,
),
const CircularProgressIndicator(),
]),
);
});
}
Widget _decodeRasterImage(WidgetRef ref, File file) {
return ClipOval(
// This clipper makes the oval small enough to hide artifacts
// on the oval border for images not supporting transparency.
clipper: _AccountIconClipper(_width, _height),
child: Image.file(
file,
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
alignment: Alignment.center,
width: _width,
height: _height,
errorBuilder: (_, __, ___) => defaultWidget,
),
);
}
}
class _AccountIconClipper extends CustomClipper<Rect> {
final double _width;
final double _height;
_AccountIconClipper(this._width, this._height);
@override
Rect getClip(Size size) {
return Rect.fromCenter(
center: Offset(_width / 2, _height / 2),
// make the rect smaller to hide artifacts
width: _width - 1,
height: _height - 1);
}
@override
bool shouldReclip(oldClipper) {
return true;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -27,6 +27,7 @@ import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
import 'account_helper.dart';
import 'account_icon.dart';
import 'actions.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
@ -153,6 +154,20 @@ class _AccountViewState extends ConsumerState<AccountView> {
final showAvatar = constraints.maxWidth >= 315;
final subtitle = helper.subtitle;
final circleAvatar = CircleAvatar(
foregroundColor: darkMode ? Colors.black : Colors.white,
backgroundColor: _iconColor(darkMode ? 300 : 400),
child: Text(
(credential.issuer ?? credential.name)
.characters
.first
.toUpperCase(),
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
),
);
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
@ -187,18 +202,8 @@ class _AccountViewState extends ConsumerState<AccountView> {
Actions.maybeInvoke(context, const CopyIntent());
},
leading: showAvatar
? CircleAvatar(
foregroundColor: darkMode ? Colors.black : Colors.white,
backgroundColor: _iconColor(darkMode ? 300 : 400),
child: Text(
(credential.issuer ?? credential.name)
.characters
.first
.toUpperCase(),
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w300),
),
)
? AccountIcon(
issuer: credential.issuer, defaultWidget: circleAvatar)
: null,
title: Text(
helper.title,

View File

@ -19,6 +19,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
import '../../app/message.dart';
import '../../app/models.dart';
@ -91,6 +92,22 @@ Widget oathBuildActions(
),
ListTitle(AppLocalizations.of(context)!.general_manage,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
key: keys.customIconsAction,
title: const Text('Custom icons'),
subtitle: const Text('Set icons for accounts'),
leading: const CircleAvatar(
child: Icon(Icons.image_outlined),
),
onTap: () async {
Navigator.of(context).pop();
await ref.read(withContextProvider)((context) => showBlurDialog(
context: context,
routeSettings:
const RouteSettings(name: 'oath_icon_pack_dialog'),
builder: (context) => const IconPackDialog(),
));
}),
ListTile(
key: keys.setOrManagePasswordAction,
title: Text(oathState.hasKey

View File

@ -7,6 +7,7 @@ import Foundation
import desktop_drop
import local_notifier
import path_provider_foundation
import screen_retriever
import shared_preferences_foundation
import tray_manager
@ -16,6 +17,7 @@ import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))

View File

@ -4,6 +4,9 @@ PODS:
- FlutterMacOS (1.0.0)
- local_notifier (0.1.0):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- screen_retriever (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@ -20,6 +23,7 @@ DEPENDENCIES:
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`)
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`)
- tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`)
@ -33,6 +37,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
local_notifier:
:path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos
screen_retriever:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
shared_preferences_foundation:
@ -48,6 +54,7 @@ SPEC CHECKSUMS:
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
tray_manager: 9064e219c56d75c476e46b9a21182087930baf90

View File

@ -18,7 +18,7 @@ packages:
source: hosted
version: "5.6.0"
archive:
dependency: transitive
dependency: "direct main"
description:
name: archive
sha256: "80e5141fafcb3361653ce308776cfd7d45e6e9fbb429e14eec571382c0c5fecb"
@ -170,7 +170,7 @@ packages:
source: hosted
version: "0.3.3+4"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67
@ -217,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: d090ae03df98b0247b82e5928f44d1b959867049d18d73635e2e0bc3f49542b9
url: "https://pub.dev"
source: hosted
version: "5.2.5"
fixnum:
dependency: transitive
description:
@ -248,6 +256,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "60fc7b78455b94e6de2333d2f95196d32cf5c22f4b0b0520a628804cb463503b"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
flutter_riverpod:
dependency: "direct main"
description:
@ -445,13 +461,45 @@ packages:
source: hosted
version: "2.1.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b
url: "https://pub.dev"
source: hosted
version: "1.8.2"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf
url: "https://pub.dev"
source: hosted
version: "1.0.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: dcea5feb97d8abf90cab9e9030b497fb7c3cbf26b7a1fe9e3ef7dcb0a1ddec95
url: "https://pub.dev"
source: hosted
version: "2.0.12"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: a776c088d671b27f6e3aa8881d64b87b3e80201c64e8869b811325de7a76c15e
url: "https://pub.dev"
source: hosted
version: "2.0.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "62a68e7e1c6c459f9289859e2fae58290c981ce21d1697faf54910fe1faa4c74"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
path_provider_linux:
dependency: transitive
description:
@ -476,6 +524,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4"
url: "https://pub.dev"
source: hosted
version: "5.1.0"
platform:
dependency: transitive
description:
@ -816,6 +872,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
vector_graphics:
dependency: "direct main"
description:
name: vector_graphics
sha256: "09562ef5f47aa84f6567495adb6b9cb2a3192b82c352623b8bd00b300d62603b"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "886e57742644ebed024dc3ade29712e37eea1b03d294fb314c0a3386243fe5a6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
vector_graphics_compiler:
dependency: "direct main"
description:
name: vector_graphics_compiler
sha256: "5d9010c4a292766c55395b2288532579a85673f8148460d1e233d98ffe10d24e"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
vector_math:
dependency: transitive
description:
@ -880,6 +960,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
xml:
dependency: transitive
description:
name: xml
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
url: "https://pub.dev"
source: hosted
version: "6.2.2"
yaml:
dependency: transitive
description:
@ -890,4 +978,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.3.0"
flutter: ">=3.5.0-0"

View File

@ -53,6 +53,13 @@ dependencies:
path: android/flutter_plugins/qrscanner_zxing
desktop_drop: ^0.4.0
url_launcher: ^6.1.7
path_provider: ^2.0.12
vector_graphics: ^1.0.1
vector_graphics_compiler: ^1.0.1
path: ^1.8.2
file_picker: ^5.2.5
archive: ^3.3.2
crypto: ^3.0.2
tray_manager: ^0.2.0
local_notifier: ^0.1.5