diff --git a/doc/Custom_Account_Icons.adoc b/doc/Custom_Account_Icons.adoc new file mode 100644 index 00000000..ff37d410 --- /dev/null +++ b/doc/Custom_Account_Icons.adoc @@ -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. diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 57f0071a..623f9161 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -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? 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 diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index bb50679c..bcb767df 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -59,6 +59,7 @@ class MainPage extends ConsumerWidget { 'licenses', 'user_interaction_prompt', 'oath_add_account', + 'oath_icon_pack_dialog', ].contains(route.settings.name); }); }); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ddb8ad87..99bd7751 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/oath/icon_provider/icon_cache.dart b/lib/oath/icon_provider/icon_cache.dart new file mode 100644 index 00000000..1c27a7f2 --- /dev/null +++ b/lib/oath/icon_provider/icon_cache.dart @@ -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 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 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 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 get _cacheDirectory async { + final supportDirectory = await getApplicationSupportDirectory(); + return Directory(_buildCacheDirectoryPath(supportDirectory.path)); + } + + Future _getFile(String fileName) async { + final supportDirectory = await getApplicationSupportDirectory(); + final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path); + return File( + join(cacheDirectoryPath, '${basenameWithoutExtension(fileName)}.dat')); + } +} + +class IconCacheMem { + final _cache = {}; + + 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((ref) => IconCache(IconCacheMem(), IconCacheFs())); diff --git a/lib/oath/icon_provider/icon_file_loader.dart b/lib/oath/icon_provider/icon_file_loader.dart new file mode 100644 index 00000000..3196dc49 --- /dev/null +++ b/lib/oath/icon_provider/icon_file_loader.dart @@ -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 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(); + } +} diff --git a/lib/oath/icon_provider/icon_pack.dart b/lib/oath/icon_provider/icon_pack.dart new file mode 100644 index 00000000..67dd498a --- /dev/null +++ b/lib/oath/icon_provider/icon_pack.dart @@ -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 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 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; + } +} diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart new file mode 100644 index 00000000..e1027027 --- /dev/null +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -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) { + return iconPack.when( + data: (IconPack? data) => + (data != null) ? _IconPackDescription(data) : null, + error: (Object error, StackTrace stackTrace) => null, + loading: () => null); + } + + Widget? _action(AsyncValue 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)); + } + }); + } + } +} diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart new file mode 100644 index 00000000..e1058ad3 --- /dev/null +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -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> { + 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 pack = const JsonDecoder().convert(packContent); + + final icons = List.from(pack['icons'].map((icon) => + IconPackIcon( + filename: icon['filename'], + category: icon['category'], + issuer: List.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 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; + 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 removePack() async { + _iconCache.memCache.clear(); + await _iconCache.fsCache.clear(); + final cleanupStatus = await _deleteDirectory(await _packDirectory); + state = const AsyncValue.data(null); + return cleanupStatus; + } + + Future _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 get _packDirectory async { + final supportDirectory = await getApplicationSupportDirectory(); + return Directory(join(supportDirectory.path, _packSubDir)); + } +} + +final iconPackProvider = + StateNotifierProvider>( + (ref) => IconPackManager(ref.watch(iconCacheProvider))); diff --git a/lib/oath/keys.dart b/lib/oath/keys.dart index 2a771d8e..a835d70b 100755 --- a/lib/oath/keys.dart +++ b/lib/oath/keys.dart @@ -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. diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart new file mode 100644 index 00000000..61e4a815 --- /dev/null +++ b/lib/oath/views/account_icon.dart @@ -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 { + 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; + } +} diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index b232068a..903a4f36 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -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 { 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 { 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, diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index d550ccba..69f37764 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -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 diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1d39e9d9..e3df0467 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f4a4b77c..5411ab5d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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 diff --git a/pubspec.lock b/pubspec.lock index 453b9cca..e145e152 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 63b8f14b..ba327ec9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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