From a456d813ac1ee2651d1ff75a932af7ef429742da Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 17 Feb 2023 15:15:20 +0100 Subject: [PATCH 01/39] use icon pack --- lib/oath/issuer_icon_provider.dart | 179 ++++++++++++++++++ lib/oath/state.dart | 4 + lib/oath/views/account_view.dart | 37 ++-- lib/oath/views/oath_screen.dart | 4 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 7 + pubspec.lock | 76 +++++++- pubspec.yaml | 4 + 8 files changed, 297 insertions(+), 16 deletions(-) create mode 100644 lib/oath/issuer_icon_provider.dart diff --git a/lib/oath/issuer_icon_provider.dart b/lib/oath/issuer_icon_provider.dart new file mode 100644 index 00000000..8ff8de9c --- /dev/null +++ b/lib/oath/issuer_icon_provider.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:vector_graphics/vector_graphics.dart'; +import 'package:vector_graphics_compiler/vector_graphics_compiler.dart'; +import 'package:yubico_authenticator/app/logging.dart'; + +final _log = Logger('issuer_icon_provider'); + +class IssuerIcon { + final String filename; + final String? category; + final List issuer; + + const IssuerIcon( + {required this.filename, required this.category, required this.issuer}); +} + +class IssuerIconPack { + final String uuid; + final String name; + final int version; + final Directory directory; + final List icons; + + const IssuerIconPack( + {required this.uuid, + required this.name, + required this.version, + required this.directory, + required this.icons}); +} + +class FileSystemCache { + + late Directory cacheDirectory; + + FileSystemCache(); + + void initialize() async { + final documentsDirectory = await getApplicationDocumentsDirectory(); + cacheDirectory = Directory('${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); + } + + File _cachedFile(String fileName) => File('${cacheDirectory.path}${fileName}_cached'); + + Future getCachedFileData(String fileName) async { + final file = _cachedFile(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) ? file.readAsBytes() : null; + } + + Future writeFileData(String fileName, Uint8List data) async { + final file = _cachedFile(fileName); + _log.debug('Storing $fileName to cache'); + if (!await file.exists()) { + await file.create(recursive: true, exclusive: false); + } + await file.writeAsBytes(data, flush: true); + } + +} + +class CachingFileLoader extends BytesLoader { + final File _file; + final FileSystemCache _cache; + + const CachingFileLoader(this._cache, this._file); + + @override + Future loadBytes(BuildContext? context) async { + _log.debug('Reading ${_file.path}'); + + final cacheFileName = 'cache_${basename(_file.path)}'; + final cachedData = await _cache.getCachedFileData(cacheFileName); + + if (cachedData != null) { + return cachedData.buffer.asByteData(); + } + + return 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: await Future.delayed(const Duration(seconds: 5)); + + await _cache.writeFileData(cacheFileName, compiledBytes); + + // sendAndExit will make sure this isn't copied. + return compiledBytes.buffer.asByteData(); + }, _file, debugLabel: 'Load Bytes'); + } +} + +class IssuerIconProvider { + final FileSystemCache _cache; + late IssuerIconPack _issuerIconPack; + + IssuerIconProvider(this._cache) { + _cache.initialize(); + } + + void readPack(String relativePackPath) async { + final documentsDirectory = await getApplicationDocumentsDirectory(); + final packDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}'); + final packFile = File('${packDirectory.path}pack.json'); + + _log.debug('Looking for file: ${packFile.path}'); + + if (!await packFile.exists()) { + _log.debug('Failed to find icons pack ${packFile.path}'); + return; + } + + var packContent = await packFile.readAsString(); + Map pack = const JsonDecoder().convert(packContent); + + final icons = List.from(pack['icons'].map((icon) => IssuerIcon( + filename: icon['filename'], + category: icon['category'], + issuer: List.from(icon['issuer'])))); + + _issuerIconPack = IssuerIconPack( + uuid: pack['uuid'], + name: pack['name'], + version: pack['version'], + directory: packDirectory, + icons: icons); + _log.debug( + 'Parsed ${_issuerIconPack.name} with ${_issuerIconPack.icons.length} icons'); + } + + VectorGraphic? issuerVectorGraphic(String issuer, Widget placeHolder) { + final matching = _issuerIconPack.icons + .where((element) => element.issuer.any((element) => element == issuer)); + final issuerImageFile = matching.isNotEmpty + ? File('${_issuerIconPack.directory.path}${matching.first.filename}') + : null; + return issuerImageFile != null && issuerImageFile.existsSync() + ? VectorGraphic( + width: 40, + height: 40, + fit: BoxFit.fill, + loader: CachingFileLoader(_cache, issuerImageFile), + placeholderBuilder: (BuildContext _) => placeHolder, + ) + : null; + } + + Image? issuerImage(String issuer) { + final matching = _issuerIconPack.icons + .where((element) => element.issuer.any((element) => element == issuer)); + return matching.isNotEmpty + ? Image.file( + File( + '${_issuerIconPack.directory.path}${matching.first.filename}.png'), + filterQuality: FilterQuality.medium) + : null; + } +} diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 03751de7..319b9a4a 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -25,6 +25,7 @@ import '../app/models.dart'; import '../app/state.dart'; import '../core/models.dart'; import '../core/state.dart'; +import 'issuer_icon_provider.dart'; import 'models.dart'; final searchProvider = @@ -201,3 +202,6 @@ class FilteredCredentialsNotifier extends StateNotifier> { }), ); } + +final issuerIconProvider = Provider( + (ref) => IssuerIconProvider(FileSystemCache())); \ No newline at end of file diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index b232068a..2bf3dc2e 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -153,6 +153,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(), @@ -186,20 +200,15 @@ class _AccountViewState extends ConsumerState { onLongPress: () { 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), - ), - ) - : null, + leading: SizedBox( + width: 40, + height: 40, + child: showAvatar + ? ref + .read(issuerIconProvider) + .issuerVectorGraphic(credential.issuer ?? '', circleAvatar) ?? + circleAvatar + : null), title: Text( helper.title, overflow: TextOverflow.fade, diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index a8e527e0..40418cf1 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -14,6 +14,8 @@ * limitations under the License. */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -39,6 +41,8 @@ class OathScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + + ref.read(issuerIconProvider).readPack('issuer_icons'); return ref.watch(oathStateProvider(devicePath)).when( loading: () => MessagePage( title: Text(AppLocalizations.of(context)!.oath_authenticator), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 48c04949..7b52f106 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import desktop_drop +import path_provider_foundation import screen_retriever import shared_preferences_foundation import url_launcher_macos @@ -13,6 +14,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 93c48787..a8c8a156 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -2,6 +2,9 @@ PODS: - desktop_drop (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - screen_retriever (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -15,6 +18,7 @@ PODS: DEPENDENCIES: - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - 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`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -25,6 +29,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos FlutterMacOS: :path: Flutter/ephemeral + 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: @@ -37,6 +43,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 diff --git a/pubspec.lock b/pubspec.lock index 5aad0eb9..63e8df86 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,13 +429,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: @@ -460,6 +492,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4" + url: "https://pub.dev" + source: hosted + version: "5.1.0" platform: dependency: transitive description: @@ -776,6 +816,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + 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: @@ -840,6 +904,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: @@ -850,4 +922,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 7c5b8e8c..527b3dd8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,10 @@ 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 dev_dependencies: integration_test: From b8580c0612582b9cbb5a0b2385ee8ae6e86cbb3f Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 17 Feb 2023 15:35:31 +0100 Subject: [PATCH 02/39] compile fix --- lib/oath/views/oath_screen.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 40418cf1..be690d42 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -14,8 +14,6 @@ * limitations under the License. */ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; From 494e99b36fd1ed5e832706b56125ede4632187b1 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 17 Feb 2023 17:35:29 +0100 Subject: [PATCH 03/39] import icon pack --- lib/about_page.dart | 32 +++++++++++ lib/oath/issuer_icon_provider.dart | 92 ++++++++++++++++++++++++++++++ pubspec.lock | 18 +++++- pubspec.yaml | 2 + 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/lib/about_page.dart b/lib/about_page.dart index ebac783a..e85bd53a 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -17,6 +17,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -29,6 +30,7 @@ import 'app/message.dart'; import 'app/state.dart'; import 'core/state.dart'; import 'desktop/state.dart'; +import 'oath/state.dart'; import 'version.dart'; import 'widgets/choice_filter_chip.dart'; import 'widgets/responsive_dialog.dart'; @@ -191,6 +193,36 @@ class AboutPage extends ConsumerWidget { }, ), ], + + ... [ + const SizedBox(height: 12.0,), + FilterChip( + label: const Text('Import icon pack'), + onSelected: (value) async { + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['zip'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + dialogTitle: 'Choose icon pack'); + if (result != null && result.files.isNotEmpty) { + final importStatus = await ref + .read(issuerIconProvider) + .importPack(result.paths.first!); + + await ref.read(withContextProvider)( + (context) async { + if (importStatus) { + showMessage(context, 'Icon pack imported'); + } else { + showMessage(context, 'Error importing icon pack'); + } + }, + ); + } + }, + ), + ] ], ), ), diff --git a/lib/oath/issuer_icon_provider.dart b/lib/oath/issuer_icon_provider.dart index 8ff8de9c..65f5a130 100644 --- a/lib/oath/issuer_icon_provider.dart +++ b/lib/oath/issuer_icon_provider.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:archive/archive.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -128,6 +129,12 @@ class IssuerIconProvider { if (!await packFile.exists()) { _log.debug('Failed to find icons pack ${packFile.path}'); + _issuerIconPack = IssuerIconPack( + uuid: '', + name: '', + version: 0, + directory: Directory(''), + icons: []); return; } @@ -149,6 +156,91 @@ class IssuerIconProvider { 'Parsed ${_issuerIconPack.name} with ${_issuerIconPack.icons.length} icons'); } + Future _cleanTempDirectory(Directory tempDirectory) async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + + if (await tempDirectory.exists()) { + _log.error('Failed to remove temp directory'); + return false; + } + + return true; + } + + Future importPack(String filePath) async { + + final packFile = File(filePath); + if (!await packFile.exists()) { + _log.error('Input file does not exist'); + return false; + } + + // copy input file to temporary folder + final documentsDirectory = await getApplicationDocumentsDirectory(); + final tempDirectory = Directory('${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); + + if (!await _cleanTempDirectory(tempDirectory)) { + _log.error('Failed to cleanup temp directory'); + return false; + } + + await tempDirectory.create(recursive: true); + final tempCopy = await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); + final bytes = await File(tempCopy.path).readAsBytes(); + + final destination = Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); + + final archive = ZipDecoder().decodeBytes(bytes); + for (final file in archive) { + final filename = file.name; + if (file.isFile) { + final data = file.content as List; + _log.debug('Writing file: ${destination.path}$filename'); + final extractedFile = File('${destination.path}$filename'); + final createdFile = await extractedFile.create(recursive: true); + await createdFile.writeAsBytes(data); + } else { + _log.debug('Writing directory: ${destination.path}$filename'); + Directory('${destination.path}$filename') + .createSync(recursive: true); + } + } + + // check that there is pack.json + final packJsonFile = File('${destination.path}pack.json'); + if (!await packJsonFile.exists()) { + _log.error('File is not a icon pack.'); + //await _cleanTempDirectory(tempDirectory); + return false; + } + + // remove old icons pack and icon pack cache + final packDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); + if (!await _cleanTempDirectory(packDirectory)) { + _log.error('Could not remove old pack directory'); + await _cleanTempDirectory(tempDirectory); + return false; + } + + final packCacheDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); + if (!await _cleanTempDirectory(packCacheDirectory)) { + _log.error('Could not remove old cache directory'); + await _cleanTempDirectory(tempDirectory); + return false; + } + + + await destination.rename(packDirectory.path); + readPack('issuer_icons'); + + await _cleanTempDirectory(tempDirectory); + return true; + } + VectorGraphic? issuerVectorGraphic(String issuer, Widget placeHolder) { final matching = _issuerIconPack.icons .where((element) => element.issuer.any((element) => element == issuer)); diff --git a/pubspec.lock b/pubspec.lock index 63e8df86..569c030a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "5.5.0" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "80e5141fafcb3361653ce308776cfd7d45e6e9fbb429e14eec571382c0c5fecb" @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 527b3dd8..f3c1c7ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: vector_graphics: ^1.0.1 vector_graphics_compiler: ^1.0.1 path: ^1.8.2 + file_picker: ^5.2.5 + archive: ^3.3.2 dev_dependencies: integration_test: From 836fee3fe9669745cd1a3ff7f37b84316a571dff Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 20 Feb 2023 11:23:39 +0100 Subject: [PATCH 04/39] use hash for cached filenames --- lib/oath/issuer_icon_provider.dart | 4 +++- pubspec.lock | 2 +- pubspec.yaml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/oath/issuer_icon_provider.dart b/lib/oath/issuer_icon_provider.dart index 65f5a130..343b792b 100644 --- a/lib/oath/issuer_icon_provider.dart +++ b/lib/oath/issuer_icon_provider.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -49,7 +50,8 @@ class FileSystemCache { cacheDirectory = Directory('${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); } - File _cachedFile(String fileName) => File('${cacheDirectory.path}${fileName}_cached'); + File _cachedFile(String fileName) => File( + cacheDirectory.path + sha256.convert(utf8.encode(fileName)).toString()); Future getCachedFileData(String fileName) async { final file = _cachedFile(fileName); diff --git a/pubspec.lock b/pubspec.lock index 569c030a..16c4f7ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -170,7 +170,7 @@ packages: source: hosted version: "0.3.3+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 diff --git a/pubspec.yaml b/pubspec.yaml index f3c1c7ba..16246c29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: path: ^1.8.2 file_picker: ^5.2.5 archive: ^3.3.2 + crypto: ^3.0.2 dev_dependencies: integration_test: From 127aacf5c7d946bfb8bc3ab17988f1abdaa5cdda Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 20 Feb 2023 15:26:59 +0100 Subject: [PATCH 05/39] ui update --- lib/about_page.dart | 32 ------- lib/oath/issuer_icon_provider.dart | 132 ++++++++++++++++------------- lib/oath/state.dart | 2 +- lib/oath/views/account_view.dart | 2 +- lib/settings_page.dart | 114 +++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 91 deletions(-) diff --git a/lib/about_page.dart b/lib/about_page.dart index e85bd53a..ebac783a 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -17,7 +17,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -30,7 +29,6 @@ import 'app/message.dart'; import 'app/state.dart'; import 'core/state.dart'; import 'desktop/state.dart'; -import 'oath/state.dart'; import 'version.dart'; import 'widgets/choice_filter_chip.dart'; import 'widgets/responsive_dialog.dart'; @@ -193,36 +191,6 @@ class AboutPage extends ConsumerWidget { }, ), ], - - ... [ - const SizedBox(height: 12.0,), - FilterChip( - label: const Text('Import icon pack'), - onSelected: (value) async { - final result = await FilePicker.platform.pickFiles( - allowedExtensions: ['zip'], - type: FileType.custom, - allowMultiple: false, - lockParentWindow: true, - dialogTitle: 'Choose icon pack'); - if (result != null && result.files.isNotEmpty) { - final importStatus = await ref - .read(issuerIconProvider) - .importPack(result.paths.first!); - - await ref.read(withContextProvider)( - (context) async { - if (importStatus) { - showMessage(context, 'Icon pack imported'); - } else { - showMessage(context, 'Error importing icon pack'); - } - }, - ); - } - }, - ), - ] ], ), ), diff --git a/lib/oath/issuer_icon_provider.dart b/lib/oath/issuer_icon_provider.dart index 343b792b..d7eb866d 100644 --- a/lib/oath/issuer_icon_provider.dart +++ b/lib/oath/issuer_icon_provider.dart @@ -39,15 +39,26 @@ class IssuerIconPack { required this.icons}); } +Future _deleteDirectory(Directory tempDirectory) async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + + if (await tempDirectory.exists()) { + _log.error('Failed to delete directory'); + return false; + } + + return true; +} + class FileSystemCache { - late Directory cacheDirectory; - FileSystemCache(); - void initialize() async { final documentsDirectory = await getApplicationDocumentsDirectory(); - cacheDirectory = Directory('${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); + cacheDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); } File _cachedFile(String fileName) => File( @@ -73,6 +84,9 @@ class FileSystemCache { await file.writeAsBytes(data, flush: true); } + Future clear() async { + await _deleteDirectory(cacheDirectory); + } } class CachingFileLoader extends BytesLoader { @@ -103,7 +117,7 @@ class CachingFileLoader extends BytesLoader { enableOverdrawOptimizer: false, ); task.finish(); - // for testing: await Future.delayed(const Duration(seconds: 5)); + // for testing await Future.delayed(const Duration(seconds: 5)); await _cache.writeFileData(cacheFileName, compiledBytes); @@ -113,30 +127,43 @@ class CachingFileLoader extends BytesLoader { } } -class IssuerIconProvider { +class IssuerIconProvider extends ChangeNotifier { final FileSystemCache _cache; - late IssuerIconPack _issuerIconPack; + IssuerIconPack? _issuerIconPack; IssuerIconProvider(this._cache) { _cache.initialize(); } - - void readPack(String relativePackPath) async { + + String? iconPackName() => _issuerIconPack != null + ? '${_issuerIconPack!.name} (${_issuerIconPack!.version})' + : null; + + Future removePack(String relativePackPath) async { + await _cache.clear(); + imageCache.clear(); + final cleanupStatus = + await _deleteDirectory(await _getPackDirectory(relativePackPath)); + _issuerIconPack = null; + notifyListeners(); + return cleanupStatus; + } + + Future _getPackDirectory(String relativePackPath) async { final documentsDirectory = await getApplicationDocumentsDirectory(); - final packDirectory = Directory( + return Directory( '${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}'); + } + + void readPack(String relativePackPath) async { + final packDirectory = await _getPackDirectory(relativePackPath); final packFile = File('${packDirectory.path}pack.json'); _log.debug('Looking for file: ${packFile.path}'); if (!await packFile.exists()) { _log.debug('Failed to find icons pack ${packFile.path}'); - _issuerIconPack = IssuerIconPack( - uuid: '', - name: '', - version: 0, - directory: Directory(''), - icons: []); + _issuerIconPack = null; return; } @@ -154,25 +181,14 @@ class IssuerIconProvider { version: pack['version'], directory: packDirectory, icons: icons); + _log.debug( - 'Parsed ${_issuerIconPack.name} with ${_issuerIconPack.icons.length} icons'); - } + 'Parsed ${_issuerIconPack!.name} with ${_issuerIconPack!.icons.length} icons'); - Future _cleanTempDirectory(Directory tempDirectory) async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - - if (await tempDirectory.exists()) { - _log.error('Failed to remove temp directory'); - return false; - } - - return true; + notifyListeners(); } Future importPack(String filePath) async { - final packFile = File(filePath); if (!await packFile.exists()) { _log.error('Input file does not exist'); @@ -181,18 +197,21 @@ class IssuerIconProvider { // copy input file to temporary folder final documentsDirectory = await getApplicationDocumentsDirectory(); - final tempDirectory = Directory('${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); + final tempDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); - if (!await _cleanTempDirectory(tempDirectory)) { + if (!await _deleteDirectory(tempDirectory)) { _log.error('Failed to cleanup temp directory'); return false; } await tempDirectory.create(recursive: true); - final tempCopy = await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); + final tempCopy = + await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); final bytes = await File(tempCopy.path).readAsBytes(); - final destination = Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); + final destination = + Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); final archive = ZipDecoder().decodeBytes(bytes); for (final file in archive) { @@ -205,8 +224,7 @@ class IssuerIconProvider { await createdFile.writeAsBytes(data); } else { _log.debug('Writing directory: ${destination.path}$filename'); - Directory('${destination.path}$filename') - .createSync(recursive: true); + Directory('${destination.path}$filename').createSync(recursive: true); } } @@ -221,33 +239,37 @@ class IssuerIconProvider { // remove old icons pack and icon pack cache final packDirectory = Directory( '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); - if (!await _cleanTempDirectory(packDirectory)) { + if (!await _deleteDirectory(packDirectory)) { _log.error('Could not remove old pack directory'); - await _cleanTempDirectory(tempDirectory); + await _deleteDirectory(tempDirectory); return false; } final packCacheDirectory = Directory( '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); - if (!await _cleanTempDirectory(packCacheDirectory)) { + if (!await _deleteDirectory(packCacheDirectory)) { _log.error('Could not remove old cache directory'); - await _cleanTempDirectory(tempDirectory); + await _deleteDirectory(tempDirectory); return false; } - await destination.rename(packDirectory.path); readPack('issuer_icons'); - await _cleanTempDirectory(tempDirectory); + await _deleteDirectory(tempDirectory); return true; } VectorGraphic? issuerVectorGraphic(String issuer, Widget placeHolder) { - final matching = _issuerIconPack.icons + if (_issuerIconPack == null) { + return null; + } + + final issuerIconPack = _issuerIconPack!; + final matching = issuerIconPack.icons .where((element) => element.issuer.any((element) => element == issuer)); final issuerImageFile = matching.isNotEmpty - ? File('${_issuerIconPack.directory.path}${matching.first.filename}') + ? File('${issuerIconPack.directory.path}${matching.first.filename}') : null; return issuerImageFile != null && issuerImageFile.existsSync() ? VectorGraphic( @@ -255,19 +277,15 @@ class IssuerIconProvider { height: 40, fit: BoxFit.fill, loader: CachingFileLoader(_cache, issuerImageFile), - placeholderBuilder: (BuildContext _) => placeHolder, - ) - : null; - } - - Image? issuerImage(String issuer) { - final matching = _issuerIconPack.icons - .where((element) => element.issuer.any((element) => element == issuer)); - return matching.isNotEmpty - ? Image.file( - File( - '${_issuerIconPack.directory.path}${matching.first.filename}.png'), - filterQuality: FilterQuality.medium) + placeholderBuilder: (BuildContext _) { + return Stack(alignment: Alignment.center, children: [ + Opacity( + opacity: 0.5, + child: placeHolder, + ), + const CircularProgressIndicator(), + ]); + }) : null; } } diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 319b9a4a..28aeb0b7 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -203,5 +203,5 @@ class FilteredCredentialsNotifier extends StateNotifier> { ); } -final issuerIconProvider = Provider( +final issuerIconProvider = ChangeNotifierProvider( (ref) => IssuerIconProvider(FileSystemCache())); \ No newline at end of file diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 2bf3dc2e..b715b00a 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -205,7 +205,7 @@ class _AccountViewState extends ConsumerState { height: 40, child: showAvatar ? ref - .read(issuerIconProvider) + .watch(issuerIconProvider) .issuerVectorGraphic(credential.issuer ?? '', circleAvatar) ?? circleAvatar : null), diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 80f3bd29..19d9eed1 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -14,13 +14,16 @@ * limitations under the License. */ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:yubico_authenticator/app/message.dart'; import 'app/logging.dart'; import 'app/state.dart'; +import 'oath/state.dart'; import 'widgets/list_title.dart'; import 'widgets/responsive_dialog.dart'; @@ -34,6 +37,8 @@ class SettingsPage extends ConsumerWidget { final themeMode = ref.watch(themeModeProvider); final theme = Theme.of(context); + + final iconPackName = ref.watch(issuerIconProvider).iconPackName(); return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.general_settings), child: Theme( @@ -76,9 +81,118 @@ class SettingsPage extends ConsumerWidget { _log.debug('Set theme mode to $mode'); }, ), + const ListTitle('Account icon pack'), + ListTile( + title: (iconPackName != null) ? const Text('Icon pack imported') : const Text('Not using icon pack'), + subtitle: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + iconPackName != null ? + Row( + children: [ + const Text('Name: ', + style: TextStyle(fontSize: 11)), + Text(iconPackName, + style: TextStyle(fontSize: 11, color: theme.colorScheme.primary)), + ], + ) : const Text('Tap to import', style: TextStyle(fontSize: 10)), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () async{ + await _showIconPackInfo(context, ref); + }, + ), + onTap: () async { + if (iconPackName != null) { + await _removeOrChangeIconPack(context, ref); + } else { + await _importIconPack(context, ref); + } + + await ref.read(withContextProvider)((context) async { + ref.invalidate(credentialsProvider); + }); + }, + ), ], ), ), ); } + + Future _importIconPack(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['zip'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + dialogTitle: 'Choose icon pack'); + if (result != null && result.files.isNotEmpty) { + final importStatus = + await ref.read(issuerIconProvider).importPack(result.paths.first!); + + await ref.read(withContextProvider)((context) async { + if (importStatus) { + showMessage(context, 'Icon pack imported'); + } else { + showMessage(context, 'Error importing icon pack'); + } + }); + } + + return false; + } + + Future _removeOrChangeIconPack(BuildContext context, WidgetRef ref) async => + await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + children: [ + ListTile( + title: const Text('Replace icon pack'), + onTap: () async { + await _importIconPack(context, ref); + await ref.read(withContextProvider)((context) async { + Navigator.pop(context); + }); + }), + ListTile( + title: const Text('Remove icon pack'), + onTap: () async { + final removePackStatus = await ref.read(issuerIconProvider).removePack('issuer_icons'); + await ref.read(withContextProvider)( + (context) async { + if (removePackStatus) { + showMessage(context, 'Icon pack removed'); + } else { + showMessage(context, 'Error removing icon pack'); + } + Navigator.pop(context); + }, + ); + }), + ], + ); + }); + + Future _showIconPackInfo(BuildContext context, WidgetRef ref) async => + await showDialog( + context: context, + builder: (BuildContext context) { + return const SimpleDialog( + children: [ + ListTile( + title: Text('About icon packs'), + subtitle: Text('Icon packs contain icons for accounts. ' + 'To use an icon-pack, download and import one\n\n' + 'The supported format is aegis-icons.'), + ) + ], + + ); + }); } From 57ad15f491af307002e70d4ff02d947783280456 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 11:40:38 +0100 Subject: [PATCH 06/39] refactor feature --- lib/oath/icon_provider/icon_cache.dart | 97 ++++++ lib/oath/icon_provider/icon_file_loader.dart | 81 +++++ lib/oath/icon_provider/icon_pack_manager.dart | 206 +++++++++++++ lib/oath/issuer_icon_provider.dart | 291 ------------------ lib/oath/state.dart | 6 +- lib/oath/views/account_icon.dart | 52 ++++ lib/oath/views/account_view.dart | 14 +- lib/oath/views/oath_screen.dart | 1 - lib/settings_page.dart | 7 +- 9 files changed, 446 insertions(+), 309 deletions(-) create mode 100644 lib/oath/icon_provider/icon_cache.dart create mode 100644 lib/oath/icon_provider/icon_file_loader.dart create mode 100644 lib/oath/icon_provider/icon_pack_manager.dart delete mode 100644 lib/oath/issuer_icon_provider.dart create mode 100644 lib/oath/views/account_icon.dart diff --git a/lib/oath/icon_provider/icon_cache.dart b/lib/oath/icon_provider/icon_cache.dart new file mode 100644 index 00000000..b3c6bea7 --- /dev/null +++ b/lib/oath/icon_provider/icon_cache.dart @@ -0,0 +1,97 @@ +/* + * 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:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.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 _getCacheDirectory(); + if (await cacheDirectory.exists()) { + try { + await cacheDirectory.delete(recursive: true); + } catch (e) { + _log.error( + 'Failed to delete cache directory ${cacheDirectory.path}', e); + } + } + } + + Future _getCacheDirectory() async { + final documentsDirectory = await getApplicationDocumentsDirectory(); + return Directory( + '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); + } + + Future _getFile(String fileName) async => + File((await _getCacheDirectory()).path + + sha256.convert(utf8.encode(fileName)).toString()); +} + +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..83fe470d --- /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(); + } +} \ No newline at end of file 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..9834039e --- /dev/null +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -0,0 +1,206 @@ +/* + * 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/material.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'; + +final _log = Logger('icon_pack_provider'); + +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, + }); +} + +class IconPackManager extends ChangeNotifier { + final IconCache _iconCache; + IconPack? _pack; + + IconPackManager(this._iconCache); + + String? iconPackName() => + _pack != null ? '${_pack!.name} (${_pack!.version})' : null; + + /// removes imported icon pack + Future removePack(String relativePackPath) async { + _iconCache.memCache.clear(); + await _iconCache.fsCache.clear(); + final cleanupStatus = + await _deleteDirectory(await _getPackDirectory(relativePackPath)); + _pack = null; + notifyListeners(); + 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 _getPackDirectory(String relativePackPath) async { + final documentsDirectory = await getApplicationDocumentsDirectory(); + return Directory( + '${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}'); + } + + void readPack(String relativePackPath) async { + final packDirectory = await _getPackDirectory(relativePackPath); + final packFile = File('${packDirectory.path}pack.json'); + + _log.debug('Looking for file: ${packFile.path}'); + + if (!await packFile.exists()) { + _log.debug('Failed to find icons pack ${packFile.path}'); + _pack = null; + return; + } + + 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'])))); + + _pack = IconPack( + uuid: pack['uuid'], + name: pack['name'], + version: pack['version'], + directory: packDirectory, + icons: icons); + + _log.debug('Parsed ${_pack!.name} with ${_pack!.icons.length} icons'); + + notifyListeners(); + } + + Future importPack(String filePath) async { + final packFile = File(filePath); + if (!await packFile.exists()) { + _log.error('Input file does not exist'); + return false; + } + + // copy input file to temporary folder + final documentsDirectory = await getApplicationDocumentsDirectory(); + final tempDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); + + if (!await _deleteDirectory(tempDirectory)) { + _log.error('Failed to cleanup temp directory'); + return false; + } + + await tempDirectory.create(recursive: true); + final tempCopy = + await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); + final bytes = await File(tempCopy.path).readAsBytes(); + + final destination = + Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); + + final archive = ZipDecoder().decodeBytes(bytes); + for (final file in archive) { + final filename = file.name; + if (file.isFile) { + final data = file.content as List; + _log.debug('Writing file: ${destination.path}$filename'); + final extractedFile = File('${destination.path}$filename'); + final createdFile = await extractedFile.create(recursive: true); + await createdFile.writeAsBytes(data); + } else { + _log.debug('Writing directory: ${destination.path}$filename'); + Directory('${destination.path}$filename').createSync(recursive: true); + } + } + + // check that there is pack.json + final packJsonFile = File('${destination.path}pack.json'); + if (!await packJsonFile.exists()) { + _log.error('File is not a icon pack.'); + //await _cleanTempDirectory(tempDirectory); + return false; + } + + // remove old icons pack and icon pack cache + final packDirectory = Directory( + '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); + if (!await _deleteDirectory(packDirectory)) { + _log.error('Could not remove old pack directory'); + await _deleteDirectory(tempDirectory); + return false; + } + + await _iconCache.fsCache.clear(); + _iconCache.memCache.clear(); + + await destination.rename(packDirectory.path); + readPack('issuer_icons'); + + await _deleteDirectory(tempDirectory); + return true; + } + + IconPack? getIconPack() => _pack; +} + +final iconPackManager = ChangeNotifierProvider((ref) { + final manager = IconPackManager(ref.watch(iconCacheProvider)); + manager.readPack('issuer_icons'); + return manager; +}); diff --git a/lib/oath/issuer_icon_provider.dart b/lib/oath/issuer_icon_provider.dart deleted file mode 100644 index d7eb866d..00000000 --- a/lib/oath/issuer_icon_provider.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:vector_graphics/vector_graphics.dart'; -import 'package:vector_graphics_compiler/vector_graphics_compiler.dart'; -import 'package:yubico_authenticator/app/logging.dart'; - -final _log = Logger('issuer_icon_provider'); - -class IssuerIcon { - final String filename; - final String? category; - final List issuer; - - const IssuerIcon( - {required this.filename, required this.category, required this.issuer}); -} - -class IssuerIconPack { - final String uuid; - final String name; - final int version; - final Directory directory; - final List icons; - - const IssuerIconPack( - {required this.uuid, - required this.name, - required this.version, - required this.directory, - required this.icons}); -} - -Future _deleteDirectory(Directory tempDirectory) async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - - if (await tempDirectory.exists()) { - _log.error('Failed to delete directory'); - return false; - } - - return true; -} - -class FileSystemCache { - late Directory cacheDirectory; - - void initialize() async { - final documentsDirectory = await getApplicationDocumentsDirectory(); - cacheDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); - } - - File _cachedFile(String fileName) => File( - cacheDirectory.path + sha256.convert(utf8.encode(fileName)).toString()); - - Future getCachedFileData(String fileName) async { - final file = _cachedFile(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) ? file.readAsBytes() : null; - } - - Future writeFileData(String fileName, Uint8List data) async { - final file = _cachedFile(fileName); - _log.debug('Storing $fileName to cache'); - if (!await file.exists()) { - await file.create(recursive: true, exclusive: false); - } - await file.writeAsBytes(data, flush: true); - } - - Future clear() async { - await _deleteDirectory(cacheDirectory); - } -} - -class CachingFileLoader extends BytesLoader { - final File _file; - final FileSystemCache _cache; - - const CachingFileLoader(this._cache, this._file); - - @override - Future loadBytes(BuildContext? context) async { - _log.debug('Reading ${_file.path}'); - - final cacheFileName = 'cache_${basename(_file.path)}'; - final cachedData = await _cache.getCachedFileData(cacheFileName); - - if (cachedData != null) { - return cachedData.buffer.asByteData(); - } - - return 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 await Future.delayed(const Duration(seconds: 5)); - - await _cache.writeFileData(cacheFileName, compiledBytes); - - // sendAndExit will make sure this isn't copied. - return compiledBytes.buffer.asByteData(); - }, _file, debugLabel: 'Load Bytes'); - } -} - -class IssuerIconProvider extends ChangeNotifier { - final FileSystemCache _cache; - IssuerIconPack? _issuerIconPack; - - IssuerIconProvider(this._cache) { - _cache.initialize(); - } - - String? iconPackName() => _issuerIconPack != null - ? '${_issuerIconPack!.name} (${_issuerIconPack!.version})' - : null; - - Future removePack(String relativePackPath) async { - await _cache.clear(); - imageCache.clear(); - final cleanupStatus = - await _deleteDirectory(await _getPackDirectory(relativePackPath)); - _issuerIconPack = null; - notifyListeners(); - return cleanupStatus; - } - - Future _getPackDirectory(String relativePackPath) async { - final documentsDirectory = await getApplicationDocumentsDirectory(); - return Directory( - '${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}'); - } - - void readPack(String relativePackPath) async { - final packDirectory = await _getPackDirectory(relativePackPath); - final packFile = File('${packDirectory.path}pack.json'); - - _log.debug('Looking for file: ${packFile.path}'); - - if (!await packFile.exists()) { - _log.debug('Failed to find icons pack ${packFile.path}'); - _issuerIconPack = null; - return; - } - - var packContent = await packFile.readAsString(); - Map pack = const JsonDecoder().convert(packContent); - - final icons = List.from(pack['icons'].map((icon) => IssuerIcon( - filename: icon['filename'], - category: icon['category'], - issuer: List.from(icon['issuer'])))); - - _issuerIconPack = IssuerIconPack( - uuid: pack['uuid'], - name: pack['name'], - version: pack['version'], - directory: packDirectory, - icons: icons); - - _log.debug( - 'Parsed ${_issuerIconPack!.name} with ${_issuerIconPack!.icons.length} icons'); - - notifyListeners(); - } - - Future importPack(String filePath) async { - final packFile = File(filePath); - if (!await packFile.exists()) { - _log.error('Input file does not exist'); - return false; - } - - // copy input file to temporary folder - final documentsDirectory = await getApplicationDocumentsDirectory(); - final tempDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); - - if (!await _deleteDirectory(tempDirectory)) { - _log.error('Failed to cleanup temp directory'); - return false; - } - - await tempDirectory.create(recursive: true); - final tempCopy = - await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); - final bytes = await File(tempCopy.path).readAsBytes(); - - final destination = - Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); - - final archive = ZipDecoder().decodeBytes(bytes); - for (final file in archive) { - final filename = file.name; - if (file.isFile) { - final data = file.content as List; - _log.debug('Writing file: ${destination.path}$filename'); - final extractedFile = File('${destination.path}$filename'); - final createdFile = await extractedFile.create(recursive: true); - await createdFile.writeAsBytes(data); - } else { - _log.debug('Writing directory: ${destination.path}$filename'); - Directory('${destination.path}$filename').createSync(recursive: true); - } - } - - // check that there is pack.json - final packJsonFile = File('${destination.path}pack.json'); - if (!await packJsonFile.exists()) { - _log.error('File is not a icon pack.'); - //await _cleanTempDirectory(tempDirectory); - return false; - } - - // remove old icons pack and icon pack cache - final packDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); - if (!await _deleteDirectory(packDirectory)) { - _log.error('Could not remove old pack directory'); - await _deleteDirectory(tempDirectory); - return false; - } - - final packCacheDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); - if (!await _deleteDirectory(packCacheDirectory)) { - _log.error('Could not remove old cache directory'); - await _deleteDirectory(tempDirectory); - return false; - } - - await destination.rename(packDirectory.path); - readPack('issuer_icons'); - - await _deleteDirectory(tempDirectory); - return true; - } - - VectorGraphic? issuerVectorGraphic(String issuer, Widget placeHolder) { - if (_issuerIconPack == null) { - return null; - } - - final issuerIconPack = _issuerIconPack!; - final matching = issuerIconPack.icons - .where((element) => element.issuer.any((element) => element == issuer)); - final issuerImageFile = matching.isNotEmpty - ? File('${issuerIconPack.directory.path}${matching.first.filename}') - : null; - return issuerImageFile != null && issuerImageFile.existsSync() - ? VectorGraphic( - width: 40, - height: 40, - fit: BoxFit.fill, - loader: CachingFileLoader(_cache, issuerImageFile), - placeholderBuilder: (BuildContext _) { - return Stack(alignment: Alignment.center, children: [ - Opacity( - opacity: 0.5, - child: placeHolder, - ), - const CircularProgressIndicator(), - ]); - }) - : null; - } -} diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 28aeb0b7..e638d6a0 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -25,7 +25,6 @@ import '../app/models.dart'; import '../app/state.dart'; import '../core/models.dart'; import '../core/state.dart'; -import 'issuer_icon_provider.dart'; import 'models.dart'; final searchProvider = @@ -201,7 +200,4 @@ class FilteredCredentialsNotifier extends StateNotifier> { return searchKey(a.credential).compareTo(searchKey(b.credential)); }), ); -} - -final issuerIconProvider = ChangeNotifierProvider( - (ref) => IssuerIconProvider(FileSystemCache())); \ No newline at end of file +} \ No newline at end of file diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart new file mode 100644 index 00000000..4b75b944 --- /dev/null +++ b/lib/oath/views/account_icon.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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_manager.dart'; +import 'package:yubico_authenticator/widgets/delayed_visibility.dart'; + +class AccountIcon extends ConsumerWidget { + final String? issuer; + final Widget defaultWidget; + + const AccountIcon({ + super.key, + required this.issuer, + required this.defaultWidget, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final iconPack = ref.watch(iconPackManager).getIconPack(); + if (iconPack == null || issuer == null) { + return defaultWidget; + } + + final matching = iconPack.icons + .where((element) => element.issuer.any((element) => element == issuer)); + final issuerImageFile = matching.isNotEmpty + ? File('${iconPack.directory.path}${matching.first.filename}') + : null; + return issuerImageFile != null && issuerImageFile.existsSync() + ? VectorGraphic( + width: 40, + height: 40, + fit: BoxFit.fill, + loader: IconFileLoader(ref, issuerImageFile), + placeholderBuilder: (BuildContext _) { + return DelayedVisibility( + delay: const Duration(milliseconds: 10), + child: Stack(alignment: Alignment.center, children: [ + Opacity( + opacity: 0.5, + child: defaultWidget, + ), + const CircularProgressIndicator(), + ]), + ); + }) + : defaultWidget; + } +} diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index b715b00a..2f28409b 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -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'; @@ -200,15 +201,10 @@ class _AccountViewState extends ConsumerState { onLongPress: () { Actions.maybeInvoke(context, const CopyIntent()); }, - leading: SizedBox( - width: 40, - height: 40, - child: showAvatar - ? ref - .watch(issuerIconProvider) - .issuerVectorGraphic(credential.issuer ?? '', circleAvatar) ?? - circleAvatar - : null), + leading: showAvatar + ? AccountIcon( + issuer: credential.issuer, defaultWidget: circleAvatar) + : null, title: Text( helper.title, overflow: TextOverflow.fade, diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index be690d42..2eadba1c 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -40,7 +40,6 @@ class OathScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - ref.read(issuerIconProvider).readPack('issuer_icons'); return ref.watch(oathStateProvider(devicePath)).when( loading: () => MessagePage( title: Text(AppLocalizations.of(context)!.oath_authenticator), diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 19d9eed1..67dba791 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -20,6 +20,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:yubico_authenticator/app/message.dart'; +import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; import 'app/logging.dart'; import 'app/state.dart'; @@ -38,7 +39,7 @@ class SettingsPage extends ConsumerWidget { final theme = Theme.of(context); - final iconPackName = ref.watch(issuerIconProvider).iconPackName(); + final iconPackName = ref.watch(iconPackManager).iconPackName(); return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.general_settings), child: Theme( @@ -132,7 +133,7 @@ class SettingsPage extends ConsumerWidget { dialogTitle: 'Choose icon pack'); if (result != null && result.files.isNotEmpty) { final importStatus = - await ref.read(issuerIconProvider).importPack(result.paths.first!); + await ref.read(iconPackManager).importPack(result.paths.first!); await ref.read(withContextProvider)((context) async { if (importStatus) { @@ -163,7 +164,7 @@ class SettingsPage extends ConsumerWidget { ListTile( title: const Text('Remove icon pack'), onTap: () async { - final removePackStatus = await ref.read(issuerIconProvider).removePack('issuer_icons'); + final removePackStatus = await ref.read(iconPackManager).removePack('issuer_icons'); await ref.read(withContextProvider)( (context) async { if (removePackStatus) { From 99cf3f18cbb7852a8b20d5e2b9ff2262a15c52ad Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 13:25:59 +0100 Subject: [PATCH 07/39] refactor icon pack manager --- lib/oath/icon_provider/icon_pack_manager.dart | 84 ++++++++++++------- lib/oath/views/account_icon.dart | 15 +--- lib/settings_page.dart | 14 ++-- 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 9834039e..97f53c2e 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -27,7 +27,7 @@ import 'package:yubico_authenticator/app/logging.dart'; import 'icon_cache.dart'; -final _log = Logger('icon_pack_provider'); +final _log = Logger('icon_pack_manager'); class IconPackIcon { final String filename; @@ -59,45 +59,40 @@ class IconPack { class IconPackManager extends ChangeNotifier { final IconCache _iconCache; + IconPack? _pack; + final _packSubDir = 'issuer_icons'; IconPackManager(this._iconCache); - String? iconPackName() => - _pack != null ? '${_pack!.name} (${_pack!.version})' : null; + bool get hasIconPack => _pack != null; - /// removes imported icon pack - Future removePack(String relativePackPath) async { - _iconCache.memCache.clear(); - await _iconCache.fsCache.clear(); - final cleanupStatus = - await _deleteDirectory(await _getPackDirectory(relativePackPath)); - _pack = null; - notifyListeners(); - return cleanupStatus; - } + String? get iconPackName => _pack?.name; - Future _deleteDirectory(Directory directory) async { - if (await directory.exists()) { - await directory.delete(recursive: true); + int? get iconPackVersion => _pack?.version; + + File? getFileForIssuer(String? issuer) { + if (_pack == null || issuer == null) { + return null; } - if (await directory.exists()) { - _log.error('Failed to delete directory'); - return false; + final pack = _pack!; + final matching = pack.icons + .where((element) => element.issuer.any((element) => element == issuer)); + + final issuerImageFile = matching.isNotEmpty + ? File('${pack.directory.path}${matching.first.filename}') + : null; + + if (issuerImageFile != null && !issuerImageFile.existsSync()) { + return null; } - return true; + return issuerImageFile; } - Future _getPackDirectory(String relativePackPath) async { - final documentsDirectory = await getApplicationDocumentsDirectory(); - return Directory( - '${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}'); - } - - void readPack(String relativePackPath) async { - final packDirectory = await _getPackDirectory(relativePackPath); + void readPack() async { + final packDirectory = await _packDirectory; final packFile = File('${packDirectory.path}pack.json'); _log.debug('Looking for file: ${packFile.path}'); @@ -190,17 +185,44 @@ class IconPackManager extends ChangeNotifier { _iconCache.memCache.clear(); await destination.rename(packDirectory.path); - readPack('issuer_icons'); + readPack(); await _deleteDirectory(tempDirectory); return true; } - IconPack? getIconPack() => _pack; + /// removes imported icon pack + Future removePack() async { + _iconCache.memCache.clear(); + await _iconCache.fsCache.clear(); + final cleanupStatus = await _deleteDirectory(await _packDirectory); + _pack = null; + notifyListeners(); + 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 documentsDirectory = await getApplicationDocumentsDirectory(); + return Directory( + '${documentsDirectory.path}${Platform.pathSeparator}$_packSubDir${Platform.pathSeparator}'); + } } final iconPackManager = ChangeNotifierProvider((ref) { final manager = IconPackManager(ref.watch(iconCacheProvider)); - manager.readPack('issuer_icons'); + manager.readPack(); return manager; }); diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index 4b75b944..4b1b3c26 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:vector_graphics/vector_graphics.dart'; @@ -19,17 +17,8 @@ class AccountIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final iconPack = ref.watch(iconPackManager).getIconPack(); - if (iconPack == null || issuer == null) { - return defaultWidget; - } - - final matching = iconPack.icons - .where((element) => element.issuer.any((element) => element == issuer)); - final issuerImageFile = matching.isNotEmpty - ? File('${iconPack.directory.path}${matching.first.filename}') - : null; - return issuerImageFile != null && issuerImageFile.existsSync() + final issuerImageFile = ref.watch(iconPackManager).getFileForIssuer(issuer); + return issuerImageFile != null ? VectorGraphic( width: 40, height: 40, diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 67dba791..ec8030bf 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -39,7 +39,9 @@ class SettingsPage extends ConsumerWidget { final theme = Theme.of(context); - final iconPackName = ref.watch(iconPackManager).iconPackName(); + final packManager = ref.watch(iconPackManager); + final hasIconPack = packManager.hasIconPack; + return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.general_settings), child: Theme( @@ -84,17 +86,17 @@ class SettingsPage extends ConsumerWidget { ), const ListTitle('Account icon pack'), ListTile( - title: (iconPackName != null) ? const Text('Icon pack imported') : const Text('Not using icon pack'), + title: hasIconPack ? const Text('Icon pack imported') : const Text('Not using icon pack'), subtitle: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - iconPackName != null ? + hasIconPack ? Row( children: [ const Text('Name: ', style: TextStyle(fontSize: 11)), - Text(iconPackName, + Text('${packManager.iconPackName} (version: ${packManager.iconPackVersion})' , style: TextStyle(fontSize: 11, color: theme.colorScheme.primary)), ], ) : const Text('Tap to import', style: TextStyle(fontSize: 10)), @@ -107,7 +109,7 @@ class SettingsPage extends ConsumerWidget { }, ), onTap: () async { - if (iconPackName != null) { + if (hasIconPack) { await _removeOrChangeIconPack(context, ref); } else { await _importIconPack(context, ref); @@ -164,7 +166,7 @@ class SettingsPage extends ConsumerWidget { ListTile( title: const Text('Remove icon pack'), onTap: () async { - final removePackStatus = await ref.read(iconPackManager).removePack('issuer_icons'); + final removePackStatus = await ref.read(iconPackManager).removePack(); await ref.read(withContextProvider)( (context) async { if (removePackStatus) { From 201332bdc893a624479526ee80cc2b263e36d738 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 13:26:45 +0100 Subject: [PATCH 08/39] add license text --- lib/oath/views/account_icon.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index 4b1b3c26..2c07dfd2 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -1,3 +1,19 @@ +/* + * 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:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:vector_graphics/vector_graphics.dart'; From 4231c40fde8273a0192bc8056ccfde2cfb0bfd73 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 13:34:39 +0100 Subject: [PATCH 09/39] make search case insensitive --- lib/oath/icon_provider/icon_pack_manager.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 97f53c2e..0fe26c08 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -77,8 +77,8 @@ class IconPackManager extends ChangeNotifier { } final pack = _pack!; - final matching = pack.icons - .where((element) => element.issuer.any((element) => element == issuer)); + final matching = pack.icons.where((element) => element.issuer + .any((element) => element == issuer.toUpperCase())); final issuerImageFile = matching.isNotEmpty ? File('${pack.directory.path}${matching.first.filename}') @@ -110,7 +110,9 @@ class IconPackManager extends ChangeNotifier { IconPackIcon( filename: icon['filename'], category: icon['category'], - issuer: List.from(icon['issuer'])))); + issuer: List.from(icon['issuer']) + .map((e) => e.toUpperCase()) + .toList(growable: false)))); _pack = IconPack( uuid: pack['uuid'], From 3158165a7e1225812c19eb0794573feff14f9f48 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 14:15:40 +0100 Subject: [PATCH 10/39] don't use ArchiveFile.isFile --- lib/oath/icon_provider/icon_pack_manager.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 0fe26c08..91c55321 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -151,17 +151,17 @@ class IconPackManager extends ChangeNotifier { final destination = Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); - final archive = ZipDecoder().decodeBytes(bytes); + final archive = ZipDecoder().decodeBytes(bytes, verify: true); for (final file in archive) { final filename = file.name; - if (file.isFile) { + if (file.size > 0) { final data = file.content as List; - _log.debug('Writing file: ${destination.path}$filename'); + _log.debug('Writing file: ${destination.path}$filename (size: ${file.size})'); final extractedFile = File('${destination.path}$filename'); final createdFile = await extractedFile.create(recursive: true); await createdFile.writeAsBytes(data); } else { - _log.debug('Writing directory: ${destination.path}$filename'); + _log.debug('Writing directory: ${destination.path}$filename (size: ${file.size})'); Directory('${destination.path}$filename').createSync(recursive: true); } } From 0a2fd4010fc511d2fe643a6b53ccb7b8d6cbe195 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 14:16:43 +0100 Subject: [PATCH 11/39] add max zip size limit and error descriptions --- lib/oath/icon_provider/icon_pack_manager.dart | 22 +++++++++++++++---- lib/settings_page.dart | 3 ++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 91c55321..61378f2f 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -61,6 +61,7 @@ class IconPackManager extends ChangeNotifier { final IconCache _iconCache; IconPack? _pack; + String? _lastError; final _packSubDir = 'issuer_icons'; IconPackManager(this._iconCache); @@ -71,6 +72,8 @@ class IconPackManager extends ChangeNotifier { int? get iconPackVersion => _pack?.version; + String? get lastError => _lastError; + File? getFileForIssuer(String? issuer) { if (_pack == null || issuer == null) { return null; @@ -130,6 +133,13 @@ class IconPackManager extends ChangeNotifier { final packFile = File(filePath); if (!await packFile.exists()) { _log.error('Input file does not exist'); + _lastError = 'File not found'; + return false; + } + + if (await packFile.length() > 3 * 1024 * 1024) { + _log.error('File exceeds size. Max 3MB.'); + _lastError = 'File exceeds size. Max 3MB.'; return false; } @@ -139,7 +149,8 @@ class IconPackManager extends ChangeNotifier { '${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); if (!await _deleteDirectory(tempDirectory)) { - _log.error('Failed to cleanup temp directory'); + _log.error('FS operation failed(1)'); + _lastError = 'FS failure(1)'; return false; } @@ -169,8 +180,9 @@ class IconPackManager extends ChangeNotifier { // check that there is pack.json final packJsonFile = File('${destination.path}pack.json'); if (!await packJsonFile.exists()) { - _log.error('File is not a icon pack.'); - //await _cleanTempDirectory(tempDirectory); + _log.error('File is not a icon pack: missing pack.json'); + _lastError = 'pack.json missing'; + await _deleteDirectory(tempDirectory); return false; } @@ -178,7 +190,8 @@ class IconPackManager extends ChangeNotifier { final packDirectory = Directory( '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); if (!await _deleteDirectory(packDirectory)) { - _log.error('Could not remove old pack directory'); + _log.error('FS operation failed(2)'); + _lastError = 'FS failure(2)'; await _deleteDirectory(tempDirectory); return false; } @@ -187,6 +200,7 @@ class IconPackManager extends ChangeNotifier { _iconCache.memCache.clear(); await destination.rename(packDirectory.path); + readPack(); await _deleteDirectory(tempDirectory); diff --git a/lib/settings_page.dart b/lib/settings_page.dart index ec8030bf..136e1d1a 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -141,7 +141,8 @@ class SettingsPage extends ConsumerWidget { if (importStatus) { showMessage(context, 'Icon pack imported'); } else { - showMessage(context, 'Error importing icon pack'); + showMessage(context, + 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); } }); } From a8100dc00fed2c6a9650065aa52c24973f5d8950 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 14:38:25 +0100 Subject: [PATCH 12/39] add icon pack settings to Android --- lib/android/views/android_settings_page.dart | 2 + .../icon_provider/icon_pack_settings.dart | 153 ++++++++++++++++++ lib/settings_page.dart | 119 +------------- 3 files changed, 157 insertions(+), 117 deletions(-) create mode 100644 lib/oath/icon_provider/icon_pack_settings.dart diff --git a/lib/android/views/android_settings_page.dart b/lib/android/views/android_settings_page.dart index 6f6ed694..fadbf301 100755 --- a/lib/android/views/android_settings_page.dart +++ b/lib/android/views/android_settings_page.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yubico_authenticator/oath/icon_provider/icon_pack_settings.dart'; import '../../app/state.dart'; import '../../core/state.dart'; @@ -203,6 +204,7 @@ class _AndroidSettingsPageState extends ConsumerState { ref.read(themeModeProvider.notifier).setThemeMode(newMode); }, ), + const IconPackSettings() ], ), ), diff --git a/lib/oath/icon_provider/icon_pack_settings.dart b/lib/oath/icon_provider/icon_pack_settings.dart new file mode 100644 index 00000000..5951265e --- /dev/null +++ b/lib/oath/icon_provider/icon_pack_settings.dart @@ -0,0 +1,153 @@ +/* + * 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/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yubico_authenticator/app/message.dart'; +import 'package:yubico_authenticator/app/state.dart'; +import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; +import 'package:yubico_authenticator/oath/state.dart'; +import 'package:yubico_authenticator/widgets/list_title.dart'; + +class IconPackSettings extends ConsumerWidget { + const IconPackSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final packManager = ref.watch(iconPackManager); + final hasIconPack = packManager.hasIconPack; + final theme = Theme.of(context); + + return Column(children: [ + const ListTitle('Account icon pack'), + ListTile( + title: hasIconPack + ? const Text('Icon pack imported') + : const Text('Not using icon pack'), + subtitle: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + hasIconPack + ? Row( + children: [ + const Text('Name: ', style: TextStyle(fontSize: 11)), + Text( + '${packManager.iconPackName} (version: ${packManager.iconPackVersion})', + style: TextStyle( + fontSize: 11, color: theme.colorScheme.primary)), + ], + ) + : const Text('Tap to import', style: TextStyle(fontSize: 10)), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () async { + await _showIconPackInfo(context, ref); + }, + ), + onTap: () async { + if (hasIconPack) { + await _removeOrChangeIconPack(context, ref); + } else { + await _importIconPack(context, ref); + } + + await ref.read(withContextProvider)((context) async { + ref.invalidate(credentialsProvider); + }); + }, + ), + ]); + } + + Future _importIconPack(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['zip'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + dialogTitle: 'Choose icon pack'); + if (result != null && result.files.isNotEmpty) { + final importStatus = + await ref.read(iconPackManager).importPack(result.paths.first!); + + await ref.read(withContextProvider)((context) async { + if (importStatus) { + showMessage(context, 'Icon pack imported'); + } else { + showMessage(context, + 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); + } + }); + } + + return false; + } + + Future _removeOrChangeIconPack( + BuildContext context, WidgetRef ref) async => + await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + children: [ + ListTile( + title: const Text('Replace icon pack'), + onTap: () async { + await _importIconPack(context, ref); + await ref.read(withContextProvider)((context) async { + Navigator.pop(context); + }); + }), + ListTile( + title: const Text('Remove icon pack'), + onTap: () async { + final removePackStatus = + await ref.read(iconPackManager).removePack(); + await ref.read(withContextProvider)( + (context) async { + if (removePackStatus) { + showMessage(context, 'Icon pack removed'); + } else { + showMessage(context, 'Error removing icon pack'); + } + Navigator.pop(context); + }, + ); + }), + ], + ); + }); + + Future _showIconPackInfo(BuildContext context, WidgetRef ref) async => + await showDialog( + context: context, + builder: (BuildContext context) { + return const SimpleDialog( + children: [ + ListTile( + title: Text('About icon packs'), + subtitle: Text('Icon packs contain icons for accounts. ' + 'To use an icon-pack, download and import one\n\n' + 'The supported format is aegis-icons.'), + ) + ], + ); + }); +} diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 136e1d1a..6bb4afbb 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -14,17 +14,14 @@ * limitations under the License. */ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; -import 'package:yubico_authenticator/app/message.dart'; -import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; +import 'package:yubico_authenticator/oath/icon_provider/icon_pack_settings.dart'; import 'app/logging.dart'; import 'app/state.dart'; -import 'oath/state.dart'; import 'widgets/list_title.dart'; import 'widgets/responsive_dialog.dart'; @@ -39,9 +36,6 @@ class SettingsPage extends ConsumerWidget { final theme = Theme.of(context); - final packManager = ref.watch(iconPackManager); - final hasIconPack = packManager.hasIconPack; - return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.general_settings), child: Theme( @@ -84,119 +78,10 @@ class SettingsPage extends ConsumerWidget { _log.debug('Set theme mode to $mode'); }, ), - const ListTitle('Account icon pack'), - ListTile( - title: hasIconPack ? const Text('Icon pack imported') : const Text('Not using icon pack'), - subtitle: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - hasIconPack ? - Row( - children: [ - const Text('Name: ', - style: TextStyle(fontSize: 11)), - Text('${packManager.iconPackName} (version: ${packManager.iconPackVersion})' , - style: TextStyle(fontSize: 11, color: theme.colorScheme.primary)), - ], - ) : const Text('Tap to import', style: TextStyle(fontSize: 10)), - ], - ), - trailing: IconButton( - icon: const Icon(Icons.info_outline_rounded), - onPressed: () async{ - await _showIconPackInfo(context, ref); - }, - ), - onTap: () async { - if (hasIconPack) { - await _removeOrChangeIconPack(context, ref); - } else { - await _importIconPack(context, ref); - } - - await ref.read(withContextProvider)((context) async { - ref.invalidate(credentialsProvider); - }); - }, - ), + const IconPackSettings() ], ), ), ); } - - Future _importIconPack(BuildContext context, WidgetRef ref) async { - final result = await FilePicker.platform.pickFiles( - allowedExtensions: ['zip'], - type: FileType.custom, - allowMultiple: false, - lockParentWindow: true, - dialogTitle: 'Choose icon pack'); - if (result != null && result.files.isNotEmpty) { - final importStatus = - await ref.read(iconPackManager).importPack(result.paths.first!); - - await ref.read(withContextProvider)((context) async { - if (importStatus) { - showMessage(context, 'Icon pack imported'); - } else { - showMessage(context, - 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); - } - }); - } - - return false; - } - - Future _removeOrChangeIconPack(BuildContext context, WidgetRef ref) async => - await showDialog( - context: context, - builder: (BuildContext context) { - return SimpleDialog( - children: [ - ListTile( - title: const Text('Replace icon pack'), - onTap: () async { - await _importIconPack(context, ref); - await ref.read(withContextProvider)((context) async { - Navigator.pop(context); - }); - }), - ListTile( - title: const Text('Remove icon pack'), - onTap: () async { - final removePackStatus = await ref.read(iconPackManager).removePack(); - await ref.read(withContextProvider)( - (context) async { - if (removePackStatus) { - showMessage(context, 'Icon pack removed'); - } else { - showMessage(context, 'Error removing icon pack'); - } - Navigator.pop(context); - }, - ); - }), - ], - ); - }); - - Future _showIconPackInfo(BuildContext context, WidgetRef ref) async => - await showDialog( - context: context, - builder: (BuildContext context) { - return const SimpleDialog( - children: [ - ListTile( - title: Text('About icon packs'), - subtitle: Text('Icon packs contain icons for accounts. ' - 'To use an icon-pack, download and import one\n\n' - 'The supported format is aegis-icons.'), - ) - ], - - ); - }); } From 3dc620855eb6dba413ec63e2562044e0ef9adaaf Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 16:55:49 +0100 Subject: [PATCH 13/39] use application support and system temp directory --- lib/oath/icon_provider/icon_cache.dart | 21 +++++--- lib/oath/icon_provider/icon_pack_manager.dart | 49 +++++++++---------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/lib/oath/icon_provider/icon_cache.dart b/lib/oath/icon_provider/icon_cache.dart index b3c6bea7..6fb565c5 100644 --- a/lib/oath/icon_provider/icon_cache.dart +++ b/lib/oath/icon_provider/icon_cache.dart @@ -48,7 +48,7 @@ class IconCacheFs { } Future clear() async { - final cacheDirectory = await _getCacheDirectory(); + final cacheDirectory = await _cacheDirectory; if (await cacheDirectory.exists()) { try { await cacheDirectory.delete(recursive: true); @@ -59,15 +59,20 @@ class IconCacheFs { } } - Future _getCacheDirectory() async { - final documentsDirectory = await getApplicationDocumentsDirectory(); - return Directory( - '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'); + String _buildCacheDirectoryPath(String supportDirectory) => + '$supportDirectory${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'; + + Future get _cacheDirectory async { + final supportDirectory = await getApplicationSupportDirectory(); + return Directory(_buildCacheDirectoryPath(supportDirectory.path)); } - Future _getFile(String fileName) async => - File((await _getCacheDirectory()).path + - sha256.convert(utf8.encode(fileName)).toString()); + Future _getFile(String fileName) async { + final supportDirectory = await getApplicationSupportDirectory(); + final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path); + return File( + cacheDirectoryPath + sha256.convert(utf8.encode(fileName)).toString()); + } } class IconCacheMem { diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 61378f2f..e70f0d90 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -80,8 +80,8 @@ class IconPackManager extends ChangeNotifier { } final pack = _pack!; - final matching = pack.icons.where((element) => element.issuer - .any((element) => element == issuer.toUpperCase())); + final matching = pack.icons.where((element) => + element.issuer.any((element) => element == issuer.toUpperCase())); final issuerImageFile = matching.isNotEmpty ? File('${pack.directory.path}${matching.first.filename}') @@ -144,41 +144,36 @@ class IconPackManager extends ChangeNotifier { } // copy input file to temporary folder - final documentsDirectory = await getApplicationDocumentsDirectory(); - final tempDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}temp${Platform.pathSeparator}'); - - if (!await _deleteDirectory(tempDirectory)) { - _log.error('FS operation failed(1)'); - _lastError = 'FS failure(1)'; - return false; - } - - await tempDirectory.create(recursive: true); - final tempCopy = - await packFile.copy('${tempDirectory.path}${basename(packFile.path)}'); + final tempDirectory = await Directory.systemTemp.createTemp('yubioath'); + final tempCopy = await packFile.copy('${tempDirectory.path}' + '${Platform.pathSeparator}' + '${basename(packFile.path)}'); final bytes = await File(tempCopy.path).readAsBytes(); - final destination = - Directory('${tempDirectory.path}ex${Platform.pathSeparator}'); + final unpackDirectory = + Directory('${tempDirectory.path}${Platform.pathSeparator}' + 'unpack${Platform.pathSeparator}'); 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; - _log.debug('Writing file: ${destination.path}$filename (size: ${file.size})'); - final extractedFile = File('${destination.path}$filename'); + _log.debug( + 'Writing file: ${unpackDirectory.path}$filename (size: ${file.size})'); + final extractedFile = File('${unpackDirectory.path}$filename'); final createdFile = await extractedFile.create(recursive: true); await createdFile.writeAsBytes(data); } else { - _log.debug('Writing directory: ${destination.path}$filename (size: ${file.size})'); - Directory('${destination.path}$filename').createSync(recursive: true); + _log.debug( + 'Writing directory: ${unpackDirectory.path}$filename (size: ${file.size})'); + Directory('${unpackDirectory.path}$filename') + .createSync(recursive: true); } } // check that there is pack.json - final packJsonFile = File('${destination.path}pack.json'); + final packJsonFile = File('${unpackDirectory.path}pack.json'); if (!await packJsonFile.exists()) { _log.error('File is not a icon pack: missing pack.json'); _lastError = 'pack.json missing'; @@ -187,8 +182,7 @@ class IconPackManager extends ChangeNotifier { } // remove old icons pack and icon pack cache - final packDirectory = Directory( - '${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}'); + final packDirectory = await _packDirectory; if (!await _deleteDirectory(packDirectory)) { _log.error('FS operation failed(2)'); _lastError = 'FS failure(2)'; @@ -199,7 +193,8 @@ class IconPackManager extends ChangeNotifier { await _iconCache.fsCache.clear(); _iconCache.memCache.clear(); - await destination.rename(packDirectory.path); + // moves unpacked files to the directory final directory + await unpackDirectory.rename(packDirectory.path); readPack(); @@ -231,9 +226,9 @@ class IconPackManager extends ChangeNotifier { } Future get _packDirectory async { - final documentsDirectory = await getApplicationDocumentsDirectory(); + final supportDirectory = await getApplicationSupportDirectory(); return Directory( - '${documentsDirectory.path}${Platform.pathSeparator}$_packSubDir${Platform.pathSeparator}'); + '${supportDirectory.path}${Platform.pathSeparator}$_packSubDir${Platform.pathSeparator}'); } } From 3fde9681d3596270a104c7d93847123dce496c81 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 21 Feb 2023 17:51:55 +0100 Subject: [PATCH 14/39] replace settings with dialog --- lib/android/views/android_settings_page.dart | 5 +- lib/oath/icon_provider/icon_pack_dialog.dart | 139 ++++++++++++++++ .../icon_provider/icon_pack_settings.dart | 153 ------------------ lib/oath/views/account_icon.dart | 15 +- lib/settings_page.dart | 2 - 5 files changed, 154 insertions(+), 160 deletions(-) create mode 100644 lib/oath/icon_provider/icon_pack_dialog.dart delete mode 100644 lib/oath/icon_provider/icon_pack_settings.dart diff --git a/lib/android/views/android_settings_page.dart b/lib/android/views/android_settings_page.dart index fadbf301..e92e1cab 100755 --- a/lib/android/views/android_settings_page.dart +++ b/lib/android/views/android_settings_page.dart @@ -17,7 +17,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:yubico_authenticator/oath/icon_provider/icon_pack_settings.dart'; import '../../app/state.dart'; import '../../core/state.dart'; @@ -169,8 +168,7 @@ class _AndroidSettingsPageState extends ConsumerState { SwitchListTile( title: const Text('Silence NFC sounds'), subtitle: nfcSilenceSounds - ? const Text( - 'No sounds will be played on NFC tap') + ? const Text('No sounds will be played on NFC tap') : const Text('Sound will play on NFC tap'), value: nfcSilenceSounds, key: keys.nfcSilenceSoundsSettings, @@ -204,7 +202,6 @@ class _AndroidSettingsPageState extends ConsumerState { ref.read(themeModeProvider.notifier).setThemeMode(newMode); }, ), - const IconPackSettings() ], ), ), 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..3b360cc3 --- /dev/null +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -0,0 +1,139 @@ +/* + * 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/material.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_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 theme = Theme.of(context); + final packManager = ref.watch(iconPackManager); + final hasIconPack = packManager.hasIconPack; + + return ResponsiveDialog( + title: const Text('Manage icons'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('By loading an external icon pack, the avatar icons ' + 'of the accounts will be easier to distinguish throught the issuers ' + 'familiar logos and colors.\n\n' + 'We recommend the Aegis icon packs which can be downloaded ' + 'from below.'), + TextButton( + child: const Text( + 'https://aegis-icons.github.io/', + style: TextStyle(decoration: TextDecoration.underline), + ), + onPressed: () async { + await launchUrl( + Uri.parse('https://aegis-icons.github.io/'), + mode: LaunchMode.externalApplication, + ); + }, + ), + const SizedBox(height: 8), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + OutlinedButton( + onPressed: () async { + await _importIconPack(context, ref); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.download, size: 16), + const SizedBox(width: 4), + hasIconPack + ? const Text('Replace icon pack') + : const Text('Load icon pack') + ]), + ), + if (hasIconPack) + OutlinedButton( + onPressed: () async { + final removePackStatus = + await ref.read(iconPackManager).removePack(); + await ref.read(withContextProvider)( + (context) async { + if (removePackStatus) { + showMessage(context, 'Icon pack removed'); + } else { + showMessage(context, 'Error removing icon pack'); + } + Navigator.pop(context); + }, + ); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: const [ + Icon(Icons.delete_rounded, size: 16), + SizedBox(width: 4), + Text('Remove icon pack') + ]), + ) + ]), + const SizedBox(height: 16), + if (hasIconPack) + Text( + 'Loaded: ${packManager.iconPackName} (version: ${packManager.iconPackVersion})', + style: + TextStyle(fontSize: 11, color: theme.colorScheme.primary), + ) + else + const Text('') + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + ), + ); + } + + Future _importIconPack(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['zip'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + dialogTitle: 'Choose icon pack'); + if (result != null && result.files.isNotEmpty) { + final importStatus = + await ref.read(iconPackManager).importPack(result.paths.first!); + + await ref.read(withContextProvider)((context) async { + if (importStatus) { + showMessage(context, 'Icon pack imported'); + } else { + showMessage(context, + 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); + } + }); + } + + return false; + } +} diff --git a/lib/oath/icon_provider/icon_pack_settings.dart b/lib/oath/icon_provider/icon_pack_settings.dart deleted file mode 100644 index 5951265e..00000000 --- a/lib/oath/icon_provider/icon_pack_settings.dart +++ /dev/null @@ -1,153 +0,0 @@ -/* - * 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/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:yubico_authenticator/app/message.dart'; -import 'package:yubico_authenticator/app/state.dart'; -import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; -import 'package:yubico_authenticator/oath/state.dart'; -import 'package:yubico_authenticator/widgets/list_title.dart'; - -class IconPackSettings extends ConsumerWidget { - const IconPackSettings({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final packManager = ref.watch(iconPackManager); - final hasIconPack = packManager.hasIconPack; - final theme = Theme.of(context); - - return Column(children: [ - const ListTitle('Account icon pack'), - ListTile( - title: hasIconPack - ? const Text('Icon pack imported') - : const Text('Not using icon pack'), - subtitle: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - hasIconPack - ? Row( - children: [ - const Text('Name: ', style: TextStyle(fontSize: 11)), - Text( - '${packManager.iconPackName} (version: ${packManager.iconPackVersion})', - style: TextStyle( - fontSize: 11, color: theme.colorScheme.primary)), - ], - ) - : const Text('Tap to import', style: TextStyle(fontSize: 10)), - ], - ), - trailing: IconButton( - icon: const Icon(Icons.info_outline_rounded), - onPressed: () async { - await _showIconPackInfo(context, ref); - }, - ), - onTap: () async { - if (hasIconPack) { - await _removeOrChangeIconPack(context, ref); - } else { - await _importIconPack(context, ref); - } - - await ref.read(withContextProvider)((context) async { - ref.invalidate(credentialsProvider); - }); - }, - ), - ]); - } - - Future _importIconPack(BuildContext context, WidgetRef ref) async { - final result = await FilePicker.platform.pickFiles( - allowedExtensions: ['zip'], - type: FileType.custom, - allowMultiple: false, - lockParentWindow: true, - dialogTitle: 'Choose icon pack'); - if (result != null && result.files.isNotEmpty) { - final importStatus = - await ref.read(iconPackManager).importPack(result.paths.first!); - - await ref.read(withContextProvider)((context) async { - if (importStatus) { - showMessage(context, 'Icon pack imported'); - } else { - showMessage(context, - 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); - } - }); - } - - return false; - } - - Future _removeOrChangeIconPack( - BuildContext context, WidgetRef ref) async => - await showDialog( - context: context, - builder: (BuildContext context) { - return SimpleDialog( - children: [ - ListTile( - title: const Text('Replace icon pack'), - onTap: () async { - await _importIconPack(context, ref); - await ref.read(withContextProvider)((context) async { - Navigator.pop(context); - }); - }), - ListTile( - title: const Text('Remove icon pack'), - onTap: () async { - final removePackStatus = - await ref.read(iconPackManager).removePack(); - await ref.read(withContextProvider)( - (context) async { - if (removePackStatus) { - showMessage(context, 'Icon pack removed'); - } else { - showMessage(context, 'Error removing icon pack'); - } - Navigator.pop(context); - }, - ); - }), - ], - ); - }); - - Future _showIconPackInfo(BuildContext context, WidgetRef ref) async => - await showDialog( - context: context, - builder: (BuildContext context) { - return const SimpleDialog( - children: [ - ListTile( - title: Text('About icon packs'), - subtitle: Text('Icon packs contain icons for accounts. ' - 'To use an icon-pack, download and import one\n\n' - 'The supported format is aegis-icons.'), - ) - ], - ); - }); -} diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index 2c07dfd2..fd5c0ed7 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -17,7 +17,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:vector_graphics/vector_graphics.dart'; +import 'package:yubico_authenticator/app/message.dart'; import 'package:yubico_authenticator/oath/icon_provider/icon_file_loader.dart'; +import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart'; import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; import 'package:yubico_authenticator/widgets/delayed_visibility.dart'; @@ -34,7 +36,7 @@ class AccountIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final issuerImageFile = ref.watch(iconPackManager).getFileForIssuer(issuer); - return issuerImageFile != null + final issuerWidget = issuerImageFile != null ? VectorGraphic( width: 40, height: 40, @@ -53,5 +55,16 @@ class AccountIcon extends ConsumerWidget { ); }) : defaultWidget; + return IconButton( + onPressed: () async { + await showBlurDialog( + context: context, + builder: (context) => const IconPackDialog(), + ); + }, + icon: issuerWidget, + tooltip: 'Select icon', + padding: const EdgeInsets.all(1.0), + ); } } diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 6bb4afbb..72878ba1 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -18,7 +18,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; -import 'package:yubico_authenticator/oath/icon_provider/icon_pack_settings.dart'; import 'app/logging.dart'; import 'app/state.dart'; @@ -78,7 +77,6 @@ class SettingsPage extends ConsumerWidget { _log.debug('Set theme mode to $mode'); }, ), - const IconPackSettings() ], ), ), From 68411efbd84992e8eb9059ad95c8498ffac79735 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 10:19:50 +0100 Subject: [PATCH 15/39] dialog styling --- lib/oath/icon_provider/icon_pack_dialog.dart | 86 ++++++++++---------- lib/oath/views/account_icon.dart | 2 +- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 3b360cc3..5592cde0 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -33,72 +33,68 @@ class IconPackDialog extends ConsumerWidget { final hasIconPack = packManager.hasIconPack; return ResponsiveDialog( - title: const Text('Manage icons'), + title: const Text('Custom icons'), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('By loading an external icon pack, the avatar icons ' - 'of the accounts will be easier to distinguish throught the issuers ' + 'of the accounts will be easier to distinguish through the issuers ' 'familiar logos and colors.\n\n' - 'We recommend the Aegis icon packs which can be downloaded ' - 'from below.'), + 'Read more about how to create or obtain a compatible icon pack ' + 'by clicking the link from below.'), TextButton( child: const Text( - 'https://aegis-icons.github.io/', - style: TextStyle(decoration: TextDecoration.underline), + 'Icon pack support', + style: TextStyle(decoration: TextDecoration.none), ), onPressed: () async { await launchUrl( - Uri.parse('https://aegis-icons.github.io/'), + Uri.parse( + 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), mode: LaunchMode.externalApplication, ); }, ), const SizedBox(height: 8), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - OutlinedButton( - onPressed: () async { - await _importIconPack(context, ref); - }, - child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.download, size: 16), - const SizedBox(width: 4), - hasIconPack - ? const Text('Replace icon pack') - : const Text('Load icon pack') - ]), - ), - if (hasIconPack) - OutlinedButton( - onPressed: () async { - final removePackStatus = - await ref.read(iconPackManager).removePack(); - await ref.read(withContextProvider)( - (context) async { - if (removePackStatus) { - showMessage(context, 'Icon pack removed'); - } else { - showMessage(context, 'Error removing icon pack'); - } - Navigator.pop(context); + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: [ + ActionChip( + onPressed: () async { + await _importIconPack(context, ref); + }, + avatar: const Icon(Icons.download_outlined, size: 16), + label: hasIconPack + ? const Text('Load icon pack') + : const Text('Load icon pack')), + if (hasIconPack) + ActionChip( + onPressed: () async { + final removePackStatus = + await ref.read(iconPackManager).removePack(); + await ref.read(withContextProvider)( + (context) async { + if (removePackStatus) { + showMessage(context, 'Icon pack removed'); + } else { + showMessage(context, 'Error removing icon pack'); + } + Navigator.pop(context); + }, + ); }, - ); - }, - child: Row(mainAxisSize: MainAxisSize.min, children: const [ - Icon(Icons.delete_rounded, size: 16), - SizedBox(width: 4), - Text('Remove icon pack') - ]), - ) - ]), + avatar: const Icon(Icons.delete_outline, size: 16), + label: const Text('Remove icon pack')) + ], + ), const SizedBox(height: 16), if (hasIconPack) Text( - 'Loaded: ${packManager.iconPackName} (version: ${packManager.iconPackVersion})', - style: - TextStyle(fontSize: 11, color: theme.colorScheme.primary), + 'Current: ${packManager.iconPackName} (version: ${packManager.iconPackVersion})', + style: TextStyle(fontSize: 11, color: theme.disabledColor), ) else const Text('') diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index fd5c0ed7..5bcc830a 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -63,7 +63,7 @@ class AccountIcon extends ConsumerWidget { ); }, icon: issuerWidget, - tooltip: 'Select icon', + tooltip: 'Manage icons', padding: const EdgeInsets.all(1.0), ); } From bfd0f91c1412ee571c876ea8b541badfc374527e Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 11:27:05 +0100 Subject: [PATCH 16/39] learn more updates --- lib/oath/icon_provider/icon_pack_dialog.dart | 49 +++++++++++--------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 5592cde0..9b69fc6f 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -15,6 +15,7 @@ */ import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -39,25 +40,15 @@ class IconPackDialog extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('By loading an external icon pack, the avatar icons ' - 'of the accounts will be easier to distinguish through the issuers ' - 'familiar logos and colors.\n\n' - 'Read more about how to create or obtain a compatible icon pack ' - 'by clicking the link from below.'), - TextButton( - child: const Text( - 'Icon pack support', - style: TextStyle(decoration: TextDecoration.none), + RichText( + text: TextSpan( + text: 'Icon packs can make your accounts more easily ' + 'distinguishable with familiar logos and colors. ', + style: TextStyle(color: theme.textTheme.bodySmall?.color), + children: [_createLearnMoreLink(context, [])], ), - onPressed: () async { - await launchUrl( - Uri.parse( - 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), - mode: LaunchMode.externalApplication, - ); - }, ), - const SizedBox(height: 8), + const SizedBox(height: 4), Wrap( spacing: 4.0, runSpacing: 8.0, @@ -67,9 +58,7 @@ class IconPackDialog extends ConsumerWidget { await _importIconPack(context, ref); }, avatar: const Icon(Icons.download_outlined, size: 16), - label: hasIconPack - ? const Text('Load icon pack') - : const Text('Load icon pack')), + label: const Text('Load icon pack')), if (hasIconPack) ActionChip( onPressed: () async { @@ -90,7 +79,7 @@ class IconPackDialog extends ConsumerWidget { label: const Text('Remove icon pack')) ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), if (hasIconPack) Text( 'Current: ${packManager.iconPackName} (version: ${packManager.iconPackVersion})', @@ -132,4 +121,22 @@ class IconPackDialog extends ConsumerWidget { return false; } + + TextSpan _createLearnMoreLink( + BuildContext context, List? children) { + final theme = Theme.of(context); + return TextSpan( + text: 'Learn\u00a0more', + style: TextStyle(color: theme.primaryColor), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await launchUrl( + Uri.parse( + 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), + mode: LaunchMode.externalApplication, + ); + }, + children: children, + ); + } } From 54f816403bae836595d6b52fdb344a7142c8bb35 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 11:42:44 +0100 Subject: [PATCH 17/39] open dialog from OATH menu --- lib/oath/icon_provider/icon_pack_dialog.dart | 2 +- lib/oath/keys.dart | 2 ++ lib/oath/views/account_icon.dart | 15 +-------------- lib/oath/views/key_actions.dart | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 9b69fc6f..62980574 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -71,7 +71,7 @@ class IconPackDialog extends ConsumerWidget { } else { showMessage(context, 'Error removing icon pack'); } - Navigator.pop(context); + // don't close the dialog Navigator.pop(context); }, ); }, diff --git a/lib/oath/keys.dart b/lib/oath/keys.dart index 2a771d8e..5cacfc02 100755 --- a/lib/oath/keys.dart +++ b/lib/oath/keys.dart @@ -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 index 5bcc830a..2c07dfd2 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -17,9 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:vector_graphics/vector_graphics.dart'; -import 'package:yubico_authenticator/app/message.dart'; import 'package:yubico_authenticator/oath/icon_provider/icon_file_loader.dart'; -import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart'; import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart'; import 'package:yubico_authenticator/widgets/delayed_visibility.dart'; @@ -36,7 +34,7 @@ class AccountIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final issuerImageFile = ref.watch(iconPackManager).getFileForIssuer(issuer); - final issuerWidget = issuerImageFile != null + return issuerImageFile != null ? VectorGraphic( width: 40, height: 40, @@ -55,16 +53,5 @@ class AccountIcon extends ConsumerWidget { ); }) : defaultWidget; - return IconButton( - onPressed: () async { - await showBlurDialog( - context: context, - builder: (context) => const IconPackDialog(), - ); - }, - icon: issuerWidget, - tooltip: 'Manage icons', - padding: const EdgeInsets.all(1.0), - ); } } diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index d550ccba..ecbd6b3f 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,20 @@ 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 showBlurDialog( + context: context, + builder: (context) => const IconPackDialog(), + ); + }), ListTile( key: keys.setOrManagePasswordAction, title: Text(oathState.hasKey From 767c9dde16441a16c5abf94ca611558800ef5cc3 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 14:23:16 +0100 Subject: [PATCH 18/39] update UI for remove icon pack --- lib/oath/icon_provider/icon_pack_dialog.dart | 74 ++++++++++++-------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 62980574..30224f89 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -40,11 +40,12 @@ class IconPackDialog extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), RichText( text: TextSpan( text: 'Icon packs can make your accounts more easily ' 'distinguishable with familiar logos and colors. ', - style: TextStyle(color: theme.textTheme.bodySmall?.color), + style: TextStyle(color: theme.textTheme.bodyLarge?.color), children: [_createLearnMoreLink(context, [])], ), ), @@ -58,35 +59,52 @@ class IconPackDialog extends ConsumerWidget { await _importIconPack(context, ref); }, avatar: const Icon(Icons.download_outlined, size: 16), - label: const Text('Load icon pack')), - if (hasIconPack) - ActionChip( - onPressed: () async { - final removePackStatus = - await ref.read(iconPackManager).removePack(); - await ref.read(withContextProvider)( - (context) async { - if (removePackStatus) { - showMessage(context, 'Icon pack removed'); - } else { - showMessage(context, 'Error removing icon pack'); - } - // don't close the dialog Navigator.pop(context); - }, - ); - }, - avatar: const Icon(Icons.delete_outline, size: 16), - label: const Text('Remove icon pack')) + label: hasIconPack + ? const Text('Replace icon pack') + : const Text('Load icon pack')), ], ), - const SizedBox(height: 8), + //const SizedBox(height: 8), if (hasIconPack) - Text( - 'Current: ${packManager.iconPackName} (version: ${packManager.iconPackVersion})', - style: TextStyle(fontSize: 11, color: theme.disabledColor), - ) - else - const Text('') + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + fit: FlexFit.loose, + child: RichText( + text: TextSpan( + text: '${packManager.iconPackName}', + style: theme.textTheme.bodyMedium, + children: [ + TextSpan(text: ' (${packManager.iconPackVersion})') + ]))), + Row( + children: [ + IconButton( + tooltip: 'Remove icon pack', + onPressed: () async { + final removePackStatus = + await ref.read(iconPackManager).removePack(); + await ref.read(withContextProvider)( + (context) async { + if (removePackStatus) { + showMessage(context, 'Icon pack removed'); + } else { + showMessage( + context, 'Error removing icon pack'); + } + // don't close the dialog Navigator.pop(context); + }, + ); + }, + icon: const Icon(Icons.delete_outline)), + //const SizedBox(width: 8) + ], + ), + ], + ), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -127,7 +145,7 @@ class IconPackDialog extends ConsumerWidget { final theme = Theme.of(context); return TextSpan( text: 'Learn\u00a0more', - style: TextStyle(color: theme.primaryColor), + style: TextStyle(color: theme.colorScheme.primary), recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrl( From 2f71b8e72b36843c92998a47eebbe142fedd4c05 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 15:23:57 +0100 Subject: [PATCH 19/39] add localization support --- lib/l10n/app_en.arb | 21 +++++++++++ lib/oath/icon_provider/icon_pack_dialog.dart | 35 +++++++++++-------- lib/oath/icon_provider/icon_pack_manager.dart | 15 ++++---- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 63332f39..2f69f10b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -81,6 +81,27 @@ "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_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", diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 30224f89..4b23e057 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -17,6 +17,7 @@ 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'; @@ -32,9 +33,10 @@ class IconPackDialog extends ConsumerWidget { final theme = Theme.of(context); final packManager = ref.watch(iconPackManager); final hasIconPack = packManager.hasIconPack; + final l10n = AppLocalizations.of(context)!; return ResponsiveDialog( - title: const Text('Custom icons'), + title: Text(l10n.oath_custom_icons), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( @@ -43,8 +45,7 @@ class IconPackDialog extends ConsumerWidget { const SizedBox(height: 4), RichText( text: TextSpan( - text: 'Icon packs can make your accounts more easily ' - 'distinguishable with familiar logos and colors. ', + text: l10n.oath_custom_icons_description, style: TextStyle(color: theme.textTheme.bodyLarge?.color), children: [_createLearnMoreLink(context, [])], ), @@ -60,8 +61,8 @@ class IconPackDialog extends ConsumerWidget { }, avatar: const Icon(Icons.download_outlined, size: 16), label: hasIconPack - ? const Text('Replace icon pack') - : const Text('Load icon pack')), + ? Text(l10n.oath_custom_icons_replace) + : Text(l10n.oath_custom_icons_load)), ], ), //const SizedBox(height: 8), @@ -83,17 +84,18 @@ class IconPackDialog extends ConsumerWidget { Row( children: [ IconButton( - tooltip: 'Remove icon pack', + tooltip: l10n.oath_custom_icons_remove, onPressed: () async { final removePackStatus = await ref.read(iconPackManager).removePack(); await ref.read(withContextProvider)( (context) async { if (removePackStatus) { - showMessage(context, 'Icon pack removed'); + showMessage(context, + l10n.oath_custom_icons_icon_pack_removed); } else { - showMessage( - context, 'Error removing icon pack'); + showMessage(context, + l10n.oath_custom_icons_err_icon_pack_remove); } // don't close the dialog Navigator.pop(context); }, @@ -117,22 +119,25 @@ class IconPackDialog extends ConsumerWidget { } Future _importIconPack(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: 'Choose icon pack'); + dialogTitle: l10n.oath_custom_icons_choose_icon_pack); if (result != null && result.files.isNotEmpty) { final importStatus = - await ref.read(iconPackManager).importPack(result.paths.first!); + await ref.read(iconPackManager).importPack(l10n, result.paths.first!); await ref.read(withContextProvider)((context) async { if (importStatus) { - showMessage(context, 'Icon pack imported'); + showMessage(context, l10n.oath_custom_icons_icon_pack_imported); } else { - showMessage(context, - 'Error importing icon pack: ${ref.read(iconPackManager).lastError}'); + showMessage( + context, + l10n.oath_custom_icons_err_icon_pack_import( + ref.read(iconPackManager).lastError ?? l10n.oath_custom_icons_err_import_general)); } }); } @@ -144,7 +149,7 @@ class IconPackDialog extends ConsumerWidget { BuildContext context, List? children) { final theme = Theme.of(context); return TextSpan( - text: 'Learn\u00a0more', + text: AppLocalizations.of(context)!.oath_custom_icons_learn_more, style: TextStyle(color: theme.colorScheme.primary), recognizer: TapGestureRecognizer() ..onTap = () async { diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index e70f0d90..89c76b7a 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:flutter/material.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'; @@ -129,17 +130,17 @@ class IconPackManager extends ChangeNotifier { notifyListeners(); } - Future importPack(String filePath) async { + Future importPack(AppLocalizations l10n, String filePath) async { final packFile = File(filePath); if (!await packFile.exists()) { _log.error('Input file does not exist'); - _lastError = 'File not found'; + _lastError = l10n.oath_custom_icons_err_file_not_found; return false; } if (await packFile.length() > 3 * 1024 * 1024) { - _log.error('File exceeds size. Max 3MB.'); - _lastError = 'File exceeds size. Max 3MB.'; + _log.error('File size too big.'); + _lastError = l10n.oath_custom_icons_err_file_too_big; return false; } @@ -176,7 +177,7 @@ class IconPackManager extends ChangeNotifier { final packJsonFile = File('${unpackDirectory.path}pack.json'); if (!await packJsonFile.exists()) { _log.error('File is not a icon pack: missing pack.json'); - _lastError = 'pack.json missing'; + _lastError = l10n.oath_custom_icons_err_invalid_icon_pack; await _deleteDirectory(tempDirectory); return false; } @@ -184,8 +185,8 @@ class IconPackManager extends ChangeNotifier { // remove old icons pack and icon pack cache final packDirectory = await _packDirectory; if (!await _deleteDirectory(packDirectory)) { - _log.error('FS operation failed(2)'); - _lastError = 'FS failure(2)'; + _log.error('Failure when deleting original pack directory'); + _lastError = l10n.oath_custom_icons_err_filesystem_error; await _deleteDirectory(tempDirectory); return false; } From dfe8b23e58bcffa3a612aab27347131911efeeee Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 15:36:44 +0100 Subject: [PATCH 20/39] fix icon dialog style/theming --- lib/oath/icon_provider/icon_pack_dialog.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 4b23e057..fcf2fba6 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -42,11 +42,10 @@ class IconPackDialog extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 4), RichText( text: TextSpan( text: l10n.oath_custom_icons_description, - style: TextStyle(color: theme.textTheme.bodyLarge?.color), + style: theme.textTheme.bodyMedium, children: [_createLearnMoreLink(context, [])], ), ), @@ -150,7 +149,7 @@ class IconPackDialog extends ConsumerWidget { final theme = Theme.of(context); return TextSpan( text: AppLocalizations.of(context)!.oath_custom_icons_learn_more, - style: TextStyle(color: theme.colorScheme.primary), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.primary), recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrl( From 5bffce7451342ac378715526ba9b2fd9be67ef91 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 15:47:44 +0100 Subject: [PATCH 21/39] fix tap gesture recognizer --- lib/oath/icon_provider/icon_pack_dialog.dart | 22 +++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index fcf2fba6..60e44d83 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -46,7 +46,7 @@ class IconPackDialog extends ConsumerWidget { text: TextSpan( text: l10n.oath_custom_icons_description, style: theme.textTheme.bodyMedium, - children: [_createLearnMoreLink(context, [])], + children: [_createLearnMoreLink(context)], ), ), const SizedBox(height: 4), @@ -136,7 +136,8 @@ class IconPackDialog extends ConsumerWidget { showMessage( context, l10n.oath_custom_icons_err_icon_pack_import( - ref.read(iconPackManager).lastError ?? l10n.oath_custom_icons_err_import_general)); + ref.read(iconPackManager).lastError ?? + l10n.oath_custom_icons_err_import_general)); } }); } @@ -144,21 +145,22 @@ class IconPackDialog extends ConsumerWidget { return false; } - TextSpan _createLearnMoreLink( - BuildContext context, List? children) { + 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), + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.primary), recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrl( - Uri.parse( - 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), - mode: LaunchMode.externalApplication, - ); + Uri.parse( + 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), + mode: LaunchMode.externalApplication); }, - children: children, + children: const [ + TextSpan(text: ' ') // without this the recognizer takes over whole row + ], ); } } From 4659cbaa8a383fecd54d5efd5759ede37408b22c Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 16:21:46 +0100 Subject: [PATCH 22/39] use simpler cache names --- lib/oath/icon_provider/icon_cache.dart | 6 ++---- pubspec.lock | 2 +- pubspec.yaml | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/oath/icon_provider/icon_cache.dart b/lib/oath/icon_provider/icon_cache.dart index 6fb565c5..85d8a4b5 100644 --- a/lib/oath/icon_provider/icon_cache.dart +++ b/lib/oath/icon_provider/icon_cache.dart @@ -14,13 +14,12 @@ * limitations under the License. */ -import 'dart:convert'; import 'dart:io'; -import 'package:crypto/crypto.dart'; 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'; @@ -70,8 +69,7 @@ class IconCacheFs { Future _getFile(String fileName) async { final supportDirectory = await getApplicationSupportDirectory(); final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path); - return File( - cacheDirectoryPath + sha256.convert(utf8.encode(fileName)).toString()); + return File('$cacheDirectoryPath${basenameWithoutExtension(fileName)}.dat'); } } diff --git a/pubspec.lock b/pubspec.lock index 16c4f7ad..569c030a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -170,7 +170,7 @@ packages: source: hosted version: "0.3.3+4" crypto: - dependency: "direct main" + dependency: transitive description: name: crypto sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 diff --git a/pubspec.yaml b/pubspec.yaml index 16246c29..f3c1c7ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,7 +59,6 @@ dependencies: path: ^1.8.2 file_picker: ^5.2.5 archive: ^3.3.2 - crypto: ^3.0.2 dev_dependencies: integration_test: From 1982daa18ae0fe1544c80f8415bcc271454f7fae Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 22 Feb 2023 16:35:18 +0100 Subject: [PATCH 23/39] add Learn more content --- doc/Custom_OATH_Account_Icons.adoc | 13 +++++++++++++ lib/oath/icon_provider/icon_pack_dialog.dart | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 doc/Custom_OATH_Account_Icons.adoc diff --git a/doc/Custom_OATH_Account_Icons.adoc b/doc/Custom_OATH_Account_Icons.adoc new file mode 100644 index 00000000..bdb73907 --- /dev/null +++ b/doc/Custom_OATH_Account_Icons.adoc @@ -0,0 +1,13 @@ +== Custom OATH Account Icons +Yubico Authenticator supports displaying icons for OATH accounts based on the account's issuer value. This feature makes it easier to navigate in the account list. + +=== Setup +To enable custom OATH account icons, provide an icon pack archive in the https://github.com/beemdevelopment/Aegis/blob/master/docs/iconpacks.md[Aegis Icon Pack format]. This archive can be built from scratch or one can use existing prebuilt packages from https://aegis-icons.github.io/ or https://github.com/alexbakker/aegis-simple-icons. + +Once the icon pack is present, use the OATH menu in the application and select "Custom Icons". In the dialog, load the icon pack file. + +Accounts which have icons in the icon pack will be rendered with the icons. + +The "Custom Icons" dialog can be used to remove or replace the currently used icon pack. + +NOTE: Yubico Authenticator only supports icons in the SVG format. diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index 60e44d83..56a263b7 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -145,6 +145,10 @@ class IconPackDialog extends ConsumerWidget { return false; } + Uri get _learnMoreUri => + Uri.parse('https://github.com/Yubico/yubioath-flutter/blob/' + 'feature/issuer_icons/doc/Custom_OATH_Account_Icons.adoc'); + TextSpan _createLearnMoreLink(BuildContext context) { final theme = Theme.of(context); return TextSpan( @@ -153,10 +157,7 @@ class IconPackDialog extends ConsumerWidget { ?.copyWith(color: theme.colorScheme.primary), recognizer: TapGestureRecognizer() ..onTap = () async { - await launchUrl( - Uri.parse( - 'https://github.com/Yubico/yubioath-flutter/tree/main/doc'), - mode: LaunchMode.externalApplication); + await launchUrl(_learnMoreUri, mode: LaunchMode.externalApplication); }, children: const [ TextSpan(text: ' ') // without this the recognizer takes over whole row From 0469561f2647b5cea875d6676a1decd679efaf3d Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 23 Feb 2023 10:46:33 +0100 Subject: [PATCH 24/39] show Icon Pack import progress --- lib/l10n/app_en.arb | 1 + lib/oath/icon_provider/icon_file_loader.dart | 2 +- lib/oath/icon_provider/icon_pack.dart | 64 +++++ lib/oath/icon_provider/icon_pack_dialog.dart | 220 ++++++++++-------- lib/oath/icon_provider/icon_pack_manager.dart | 108 +++------ lib/oath/views/account_icon.dart | 47 ++-- 6 files changed, 253 insertions(+), 189 deletions(-) create mode 100644 lib/oath/icon_provider/icon_pack.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f69f10b..00eb83f5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -84,6 +84,7 @@ "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": "Icon pack is loading...", "oath_custom_icons_load": "Load icon pack", "oath_custom_icons_remove": "Remove icon pack", "oath_custom_icons_icon_pack_removed": "Icon pack removed", diff --git a/lib/oath/icon_provider/icon_file_loader.dart b/lib/oath/icon_provider/icon_file_loader.dart index 83fe470d..3196dc49 100644 --- a/lib/oath/icon_provider/icon_file_loader.dart +++ b/lib/oath/icon_provider/icon_file_loader.dart @@ -78,4 +78,4 @@ class IconFileLoader extends BytesLoader { await fsCache.write(cacheFileName, decodedData); return decodedData.buffer.asByteData(); } -} \ No newline at end of file +} diff --git a/lib/oath/icon_provider/icon_pack.dart b/lib/oath/icon_provider/icon_pack.dart new file mode 100644 index 00000000..0833986a --- /dev/null +++ b/lib/oath/icon_provider/icon_pack.dart @@ -0,0 +1,64 @@ +/* + * 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'; + +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('${directory.path}${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 index 56a263b7..dabea875 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -22,6 +22,7 @@ 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'; @@ -30,11 +31,8 @@ class IconPackDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final packManager = ref.watch(iconPackManager); - final hasIconPack = packManager.hasIconPack; final l10n = AppLocalizations.of(context)!; - + final iconPack = ref.watch(iconPackProvider); return ResponsiveDialog( title: Text(l10n.oath_custom_icons), child: Padding( @@ -42,70 +40,10 @@ class IconPackDialog extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - text: TextSpan( - text: l10n.oath_custom_icons_description, - style: theme.textTheme.bodyMedium, - children: [_createLearnMoreLink(context)], - ), - ), + _DialogDescription(), const SizedBox(height: 4), - Wrap( - spacing: 4.0, - runSpacing: 8.0, - children: [ - ActionChip( - onPressed: () async { - await _importIconPack(context, ref); - }, - avatar: const Icon(Icons.download_outlined, size: 16), - label: hasIconPack - ? Text(l10n.oath_custom_icons_replace) - : Text(l10n.oath_custom_icons_load)), - ], - ), - //const SizedBox(height: 8), - if (hasIconPack) - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - fit: FlexFit.loose, - child: RichText( - text: TextSpan( - text: '${packManager.iconPackName}', - style: theme.textTheme.bodyMedium, - children: [ - TextSpan(text: ' (${packManager.iconPackVersion})') - ]))), - Row( - children: [ - IconButton( - tooltip: l10n.oath_custom_icons_remove, - onPressed: () async { - final removePackStatus = - await ref.read(iconPackManager).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); - } - // don't close the dialog Navigator.pop(context); - }, - ); - }, - icon: const Icon(Icons.delete_outline)), - //const SizedBox(width: 8) - ], - ), - ], - ), + _action(iconPack, l10n), + _loadedIconPackRow(iconPack), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -117,32 +55,39 @@ class IconPackDialog extends ConsumerWidget { ); } - Future _importIconPack(BuildContext context, WidgetRef ref) async { + 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)!; - 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(iconPackManager).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(iconPackManager).lastError ?? - l10n.oath_custom_icons_err_import_general)); - } - }); - } - - return false; + return RichText( + text: TextSpan( + text: l10n.oath_custom_icons_description, + style: theme.textTheme.bodyMedium, + children: [_createLearnMoreLink(context)], + ), + ); } Uri get _learnMoreUri => @@ -165,3 +110,94 @@ class IconPackDialog extends ConsumerWidget { ); } } + +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 index 89c76b7a..fac3bf91 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -18,7 +18,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -27,74 +26,22 @@ 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 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, - }); -} - -class IconPackManager extends ChangeNotifier { +class IconPackManager extends StateNotifier> { final IconCache _iconCache; - IconPack? _pack; String? _lastError; final _packSubDir = 'issuer_icons'; - IconPackManager(this._iconCache); - - bool get hasIconPack => _pack != null; - - String? get iconPackName => _pack?.name; - - int? get iconPackVersion => _pack?.version; + IconPackManager(this._iconCache) : super(const AsyncValue.data(null)) { + readPack(); + } String? get lastError => _lastError; - File? getFileForIssuer(String? issuer) { - if (_pack == null || issuer == null) { - return null; - } - - final pack = _pack!; - final matching = pack.icons.where((element) => - element.issuer.any((element) => element == issuer.toUpperCase())); - - final issuerImageFile = matching.isNotEmpty - ? File('${pack.directory.path}${matching.first.filename}') - : null; - - if (issuerImageFile != null && !issuerImageFile.existsSync()) { - return null; - } - - return issuerImageFile; - } - void readPack() async { final packDirectory = await _packDirectory; final packFile = File('${packDirectory.path}pack.json'); @@ -103,7 +50,8 @@ class IconPackManager extends ChangeNotifier { if (!await packFile.exists()) { _log.debug('Failed to find icons pack ${packFile.path}'); - _pack = null; + state = AsyncValue.error( + 'Failed to find icons pack ${packFile.path}', StackTrace.current); return; } @@ -118,29 +66,37 @@ class IconPackManager extends ChangeNotifier { .map((e) => e.toUpperCase()) .toList(growable: false)))); - _pack = IconPack( + state = AsyncValue.data(IconPack( uuid: pack['uuid'], name: pack['name'], version: pack['version'], directory: packDirectory, - icons: icons); + icons: icons)); - _log.debug('Parsed ${_pack!.name} with ${_pack!.icons.length} icons'); - - notifyListeners(); + _log.debug( + 'Parsed ${state.value?.name} with ${state.value?.icons.length} icons'); } 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() > 3 * 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; } @@ -166,8 +122,8 @@ class IconPackManager extends ChangeNotifier { final createdFile = await extractedFile.create(recursive: true); await createdFile.writeAsBytes(data); } else { - _log.debug( - 'Writing directory: ${unpackDirectory.path}$filename (size: ${file.size})'); + _log.debug('Writing directory: ' + '${unpackDirectory.path}$filename (size: ${file.size})'); Directory('${unpackDirectory.path}$filename') .createSync(recursive: true); } @@ -176,8 +132,9 @@ class IconPackManager extends ChangeNotifier { // check that there is pack.json final packJsonFile = File('${unpackDirectory.path}pack.json'); if (!await packJsonFile.exists()) { - _log.error('File is not a icon pack: missing pack.json'); + _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; } @@ -187,6 +144,8 @@ class IconPackManager extends ChangeNotifier { 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; } @@ -208,8 +167,7 @@ class IconPackManager extends ChangeNotifier { _iconCache.memCache.clear(); await _iconCache.fsCache.clear(); final cleanupStatus = await _deleteDirectory(await _packDirectory); - _pack = null; - notifyListeners(); + state = const AsyncValue.data(null); return cleanupStatus; } @@ -228,13 +186,11 @@ class IconPackManager extends ChangeNotifier { Future get _packDirectory async { final supportDirectory = await getApplicationSupportDirectory(); - return Directory( - '${supportDirectory.path}${Platform.pathSeparator}$_packSubDir${Platform.pathSeparator}'); + return Directory('${supportDirectory.path}${Platform.pathSeparator}' + '$_packSubDir${Platform.pathSeparator}'); } } -final iconPackManager = ChangeNotifierProvider((ref) { - final manager = IconPackManager(ref.watch(iconCacheProvider)); - manager.readPack(); - return manager; -}); +final iconPackProvider = + StateNotifierProvider>( + (ref) => IconPackManager(ref.watch(iconCacheProvider))); diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index 2c07dfd2..68afab51 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; @@ -33,25 +34,31 @@ class AccountIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final issuerImageFile = ref.watch(iconPackManager).getFileForIssuer(issuer); - return issuerImageFile != null - ? VectorGraphic( - width: 40, - height: 40, - fit: BoxFit.fill, - loader: IconFileLoader(ref, issuerImageFile), - placeholderBuilder: (BuildContext _) { - return DelayedVisibility( - delay: const Duration(milliseconds: 10), - child: Stack(alignment: Alignment.center, children: [ - Opacity( - opacity: 0.5, - child: defaultWidget, - ), - const CircularProgressIndicator(), - ]), - ); - }) - : defaultWidget; + final iconPack = ref.watch(iconPackProvider); + return iconPack.when( + data: (IconPack? iconPack) { + final issuerImageFile = iconPack?.getFileForIssuer(issuer); + return issuerImageFile != null + ? VectorGraphic( + width: 40, + height: 40, + fit: BoxFit.fill, + loader: IconFileLoader(ref, issuerImageFile), + placeholderBuilder: (BuildContext _) { + return DelayedVisibility( + delay: const Duration(milliseconds: 10), + child: Stack(alignment: Alignment.center, children: [ + Opacity( + opacity: 0.5, + child: defaultWidget, + ), + const CircularProgressIndicator(), + ]), + ); + }) + : defaultWidget; + }, + error: (_, __) => defaultWidget, + loading: () => defaultWidget); } } From 9b39b5c3912fe097afeca8164e461de4e0613a9f Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 23 Feb 2023 16:38:03 +0100 Subject: [PATCH 25/39] use join --- lib/oath/icon_provider/icon_cache.dart | 5 +-- lib/oath/icon_provider/icon_pack.dart | 4 ++- lib/oath/icon_provider/icon_pack_manager.dart | 31 ++++++++----------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/lib/oath/icon_provider/icon_cache.dart b/lib/oath/icon_provider/icon_cache.dart index 85d8a4b5..1c27a7f2 100644 --- a/lib/oath/icon_provider/icon_cache.dart +++ b/lib/oath/icon_provider/icon_cache.dart @@ -59,7 +59,7 @@ class IconCacheFs { } String _buildCacheDirectoryPath(String supportDirectory) => - '$supportDirectory${Platform.pathSeparator}issuer_icons_cache${Platform.pathSeparator}'; + join(supportDirectory, 'issuer_icons_cache'); Future get _cacheDirectory async { final supportDirectory = await getApplicationSupportDirectory(); @@ -69,7 +69,8 @@ class IconCacheFs { Future _getFile(String fileName) async { final supportDirectory = await getApplicationSupportDirectory(); final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path); - return File('$cacheDirectoryPath${basenameWithoutExtension(fileName)}.dat'); + return File( + join(cacheDirectoryPath, '${basenameWithoutExtension(fileName)}.dat')); } } diff --git a/lib/oath/icon_provider/icon_pack.dart b/lib/oath/icon_provider/icon_pack.dart index 0833986a..26cc0b83 100644 --- a/lib/oath/icon_provider/icon_pack.dart +++ b/lib/oath/icon_provider/icon_pack.dart @@ -16,6 +16,8 @@ import 'dart:io'; +import 'package:path/path.dart'; + class IconPackIcon { final String filename; final String? category; @@ -52,7 +54,7 @@ class IconPack { element.issuer.any((element) => element == issuer.toUpperCase())); final issuerImageFile = matching.isNotEmpty - ? File('${directory.path}${matching.first.filename}') + ? File(join(directory.path, matching.first.filename)) : null; if (issuerImageFile != null && !issuerImageFile.existsSync()) { diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index fac3bf91..4db53b5c 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -44,7 +44,7 @@ class IconPackManager extends StateNotifier> { void readPack() async { final packDirectory = await _packDirectory; - final packFile = File('${packDirectory.path}pack.json'); + final packFile = File(join(packDirectory.path, 'pack.json')); _log.debug('Looking for file: ${packFile.path}'); @@ -78,7 +78,6 @@ class IconPackManager extends StateNotifier> { } Future importPack(AppLocalizations l10n, String filePath) async { - // remove existing pack first await removePack(); @@ -102,35 +101,32 @@ class IconPackManager extends StateNotifier> { // copy input file to temporary folder final tempDirectory = await Directory.systemTemp.createTemp('yubioath'); - final tempCopy = await packFile.copy('${tempDirectory.path}' - '${Platform.pathSeparator}' - '${basename(packFile.path)}'); + final tempCopy = + await packFile.copy(join(tempDirectory.path, basename(packFile.path))); final bytes = await File(tempCopy.path).readAsBytes(); - final unpackDirectory = - Directory('${tempDirectory.path}${Platform.pathSeparator}' - 'unpack${Platform.pathSeparator}'); + 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; - _log.debug( - 'Writing file: ${unpackDirectory.path}$filename (size: ${file.size})'); - final extractedFile = File('${unpackDirectory.path}$filename'); + final extractedFile = File(join(unpackDirectory.path, filename)); + _log.debug('Writing file: ${extractedFile.path} (size: ${file.size})'); final createdFile = await extractedFile.create(recursive: true); await createdFile.writeAsBytes(data); } else { - _log.debug('Writing directory: ' - '${unpackDirectory.path}$filename (size: ${file.size})'); - Directory('${unpackDirectory.path}$filename') - .createSync(recursive: true); + final extractedDirectory = + Directory(join(unpackDirectory.path, filename)); + _log.debug('Writing directory: ${extractedDirectory.path} ' + '(size: ${file.size})'); + extractedDirectory.createSync(recursive: true); } } // check that there is pack.json - final packJsonFile = File('${unpackDirectory.path}pack.json'); + final packJsonFile = File(join(unpackDirectory.path, '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; @@ -186,8 +182,7 @@ class IconPackManager extends StateNotifier> { Future get _packDirectory async { final supportDirectory = await getApplicationSupportDirectory(); - return Directory('${supportDirectory.path}${Platform.pathSeparator}' - '$_packSubDir${Platform.pathSeparator}'); + return Directory(join(supportDirectory.path, _packSubDir)); } } From d735a03dd5d865463f39ebc90a6b77c101be0ca6 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 23 Feb 2023 16:59:42 +0100 Subject: [PATCH 26/39] move space character from resources to code --- lib/l10n/app_en.arb | 2 +- lib/oath/icon_provider/icon_pack_dialog.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00eb83f5..6cabfd84 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -82,7 +82,7 @@ } }, "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_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": "Icon pack is loading...", "oath_custom_icons_load": "Load icon pack", diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index dabea875..cecbcb66 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -85,7 +85,7 @@ class _DialogDescription extends ConsumerWidget { text: TextSpan( text: l10n.oath_custom_icons_description, style: theme.textTheme.bodyMedium, - children: [_createLearnMoreLink(context)], + children: [const TextSpan(text: ' '), _createLearnMoreLink(context)], ), ); } From 9abe253a99868d32b68cd2ac94158ecec69bdefb Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 24 Feb 2023 09:44:53 +0100 Subject: [PATCH 27/39] change max icon pack size to 5MB --- lib/oath/icon_provider/icon_pack_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 4db53b5c..7e2c20b5 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -92,7 +92,7 @@ class IconPackManager extends StateNotifier> { return false; } - if (await packFile.length() > 3 * 1024 * 1024) { + 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); From 89409b14b8aae7e37f522ad35c229b244f8ad83e Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 24 Feb 2023 09:54:21 +0100 Subject: [PATCH 28/39] support png and jpg formats --- doc/Custom_OATH_Account_Icons.adoc | 2 - lib/oath/views/account_icon.dart | 98 ++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/doc/Custom_OATH_Account_Icons.adoc b/doc/Custom_OATH_Account_Icons.adoc index bdb73907..83bf2a9a 100644 --- a/doc/Custom_OATH_Account_Icons.adoc +++ b/doc/Custom_OATH_Account_Icons.adoc @@ -9,5 +9,3 @@ Once the icon pack is present, use the OATH menu in the application and select " Accounts which have icons in the icon pack will be rendered with the icons. The "Custom Icons" dialog can be used to remove or replace the currently used icon pack. - -NOTE: Yubico Authenticator only supports icons in the SVG format. diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index 68afab51..d736dde6 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -14,8 +14,11 @@ * 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'; @@ -26,6 +29,9 @@ 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, @@ -38,27 +44,81 @@ class AccountIcon extends ConsumerWidget { return iconPack.when( data: (IconPack? iconPack) { final issuerImageFile = iconPack?.getFileForIssuer(issuer); - return issuerImageFile != null - ? VectorGraphic( - width: 40, - height: 40, - fit: BoxFit.fill, - loader: IconFileLoader(ref, issuerImageFile), - placeholderBuilder: (BuildContext _) { - return DelayedVisibility( - delay: const Duration(milliseconds: 10), - child: Stack(alignment: Alignment.center, children: [ - Opacity( - opacity: 0.5, - child: defaultWidget, - ), - const CircularProgressIndicator(), - ]), - ); - }) - : defaultWidget; + 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, + ), + ); + } +} + +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; + } } From 53b3ba134a2167653b98858a152cc95b5e0c1f72 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 24 Feb 2023 10:59:12 +0100 Subject: [PATCH 29/39] prevent Icon Pack Dialog closing on YK change --- lib/app/views/main_page.dart | 1 + lib/oath/views/key_actions.dart | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index ecbd6b3f..69f37764 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -101,10 +101,12 @@ Widget oathBuildActions( ), onTap: () async { Navigator.of(context).pop(); - await showBlurDialog( + await ref.read(withContextProvider)((context) => showBlurDialog( context: context, + routeSettings: + const RouteSettings(name: 'oath_icon_pack_dialog'), builder: (context) => const IconPackDialog(), - ); + )); }), ListTile( key: keys.setOrManagePasswordAction, From 45ff81a443ab0560f0572f63976680418f0eeeaa Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 24 Feb 2023 11:25:24 +0100 Subject: [PATCH 30/39] change copy for Loading icon pack --- lib/l10n/app_en.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6cabfd84..3ab34cfa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -84,7 +84,7 @@ "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": "Icon pack is loading...", + "oath_custom_icons_loading": "Loading icon pack...", "oath_custom_icons_load": "Load icon pack", "oath_custom_icons_remove": "Remove icon pack", "oath_custom_icons_icon_pack_removed": "Icon pack removed", From ffe7f72aa3f0e5dc3ba9b04c7fd718643bdfaf80 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 24 Feb 2023 11:32:45 +0100 Subject: [PATCH 31/39] use ellipsis character --- lib/l10n/app_en.arb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3ab34cfa..5e24a3ce 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -84,7 +84,7 @@ "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...", + "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", @@ -108,7 +108,7 @@ "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", @@ -141,7 +141,7 @@ "general_configure_yubikey": "Configure YubiKey", "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", @@ -228,7 +228,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", @@ -256,7 +256,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", From a4cc159f600c181591a63d5f9b09427fc4fd85a8 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 27 Feb 2023 17:02:30 +0100 Subject: [PATCH 32/39] hash icon pack file names --- lib/oath/icon_provider/icon_pack.dart | 9 ++++++++- lib/oath/icon_provider/icon_pack_manager.dart | 13 ++++--------- pubspec.lock | 2 +- pubspec.yaml | 1 + 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack.dart b/lib/oath/icon_provider/icon_pack.dart index 26cc0b83..67dd498a 100644 --- a/lib/oath/icon_provider/icon_pack.dart +++ b/lib/oath/icon_provider/icon_pack.dart @@ -14,10 +14,17 @@ * 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; @@ -54,7 +61,7 @@ class IconPack { element.issuer.any((element) => element == issuer.toUpperCase())); final issuerImageFile = matching.isNotEmpty - ? File(join(directory.path, matching.first.filename)) + ? File(join(directory.path, getLocalIconFileName(matching.first.filename))) : null; if (issuerImageFile != null && !issuerImageFile.existsSync()) { diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 7e2c20b5..5f4e2255 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -44,7 +44,7 @@ class IconPackManager extends StateNotifier> { void readPack() async { final packDirectory = await _packDirectory; - final packFile = File(join(packDirectory.path, 'pack.json')); + final packFile = File(join(packDirectory.path, getLocalIconFileName('pack.json'))); _log.debug('Looking for file: ${packFile.path}'); @@ -112,21 +112,16 @@ class IconPackManager extends StateNotifier> { final filename = file.name; if (file.size > 0) { final data = file.content as List; - final extractedFile = File(join(unpackDirectory.path, filename)); + 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); - } else { - final extractedDirectory = - Directory(join(unpackDirectory.path, filename)); - _log.debug('Writing directory: ${extractedDirectory.path} ' - '(size: ${file.size})'); - extractedDirectory.createSync(recursive: true); } } // check that there is pack.json - final packJsonFile = File(join(unpackDirectory.path, '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; diff --git a/pubspec.lock b/pubspec.lock index 569c030a..16c4f7ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -170,7 +170,7 @@ packages: source: hosted version: "0.3.3+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 diff --git a/pubspec.yaml b/pubspec.yaml index f3c1c7ba..16246c29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: path: ^1.8.2 file_picker: ^5.2.5 archive: ^3.3.2 + crypto: ^3.0.2 dev_dependencies: integration_test: From 2f2fca78ff91d616d0ad44b430bb56001811365c Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 12:09:02 +0100 Subject: [PATCH 33/39] content validation --- lib/oath/icon_provider/icon_pack_manager.dart | 61 +++++++++++++------ lib/oath/views/account_icon.dart | 1 + 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/lib/oath/icon_provider/icon_pack_manager.dart b/lib/oath/icon_provider/icon_pack_manager.dart index 5f4e2255..e1058ad3 100644 --- a/lib/oath/icon_provider/icon_pack_manager.dart +++ b/lib/oath/icon_provider/icon_pack_manager.dart @@ -44,37 +44,45 @@ class IconPackManager extends StateNotifier> { void readPack() async { final packDirectory = await _packDirectory; - final packFile = File(join(packDirectory.path, getLocalIconFileName('pack.json'))); + 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 icons pack ${packFile.path}', StackTrace.current); + 'Failed to find icon pack ${packFile.path}', StackTrace.current); return; } - var packContent = await packFile.readAsString(); - Map pack = const JsonDecoder().convert(packContent); + 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)))); + 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)); + 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'); + _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 { @@ -121,7 +129,8 @@ class IconPackManager extends StateNotifier> { } // check that there is pack.json - final packJsonFile = File(join(unpackDirectory.path, getLocalIconFileName('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; @@ -130,6 +139,18 @@ class IconPackManager extends StateNotifier> { 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)) { diff --git a/lib/oath/views/account_icon.dart b/lib/oath/views/account_icon.dart index d736dde6..61e4a815 100644 --- a/lib/oath/views/account_icon.dart +++ b/lib/oath/views/account_icon.dart @@ -97,6 +97,7 @@ class AccountIcon extends ConsumerWidget { alignment: Alignment.center, width: _width, height: _height, + errorBuilder: (_, __, ___) => defaultWidget, ), ); } From a30f6e65c097b4e3753ef3b4f95bf7bbc24b8c8b Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 13:48:43 +0100 Subject: [PATCH 34/39] revert path_provider_windows merge downgrade --- pubspec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index db039449..e145e152 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,10 +520,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: bcabbe399d4042b8ee687e17548d5d3f527255253b4a639f5f8d2094a9c2b45c + sha256: "642ddf65fde5404f83267e8459ddb4556316d3ee6d511ed193357e25caa3632d" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" petitparser: dependency: transitive description: From 821cdfb11869032a3d7e57253177ed48388ff11f Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 28 Feb 2023 15:07:37 +0100 Subject: [PATCH 35/39] Update custom icons doc and link. --- doc/Custom_Account_Icons.adoc | 22 ++++++++++++++++++++ doc/Custom_OATH_Account_Icons.adoc | 11 ---------- lib/oath/icon_provider/icon_pack_dialog.dart | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 doc/Custom_Account_Icons.adoc delete mode 100644 doc/Custom_OATH_Account_Icons.adoc 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/doc/Custom_OATH_Account_Icons.adoc b/doc/Custom_OATH_Account_Icons.adoc deleted file mode 100644 index 83bf2a9a..00000000 --- a/doc/Custom_OATH_Account_Icons.adoc +++ /dev/null @@ -1,11 +0,0 @@ -== Custom OATH Account Icons -Yubico Authenticator supports displaying icons for OATH accounts based on the account's issuer value. This feature makes it easier to navigate in the account list. - -=== Setup -To enable custom OATH account icons, provide an icon pack archive in the https://github.com/beemdevelopment/Aegis/blob/master/docs/iconpacks.md[Aegis Icon Pack format]. This archive can be built from scratch or one can use existing prebuilt packages from https://aegis-icons.github.io/ or https://github.com/alexbakker/aegis-simple-icons. - -Once the icon pack is present, use the OATH menu in the application and select "Custom Icons". In the dialog, load the icon pack file. - -Accounts which have icons in the icon pack will be rendered with the icons. - -The "Custom Icons" dialog can be used to remove or replace the currently used icon pack. diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index cecbcb66..e1027027 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -92,7 +92,7 @@ class _DialogDescription extends ConsumerWidget { Uri get _learnMoreUri => Uri.parse('https://github.com/Yubico/yubioath-flutter/blob/' - 'feature/issuer_icons/doc/Custom_OATH_Account_Icons.adoc'); + 'main/doc/Custom_Account_Icons.adoc'); TextSpan _createLearnMoreLink(BuildContext context) { final theme = Theme.of(context); From b63b8f0a2e11dbc74bd3f2e002b701da98861efd Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 15:21:22 +0100 Subject: [PATCH 36/39] revert non-changes --- lib/android/views/android_settings_page.dart | 3 ++- lib/oath/state.dart | 2 +- lib/oath/views/oath_screen.dart | 1 - lib/settings_page.dart | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/android/views/android_settings_page.dart b/lib/android/views/android_settings_page.dart index e92e1cab..6f6ed694 100755 --- a/lib/android/views/android_settings_page.dart +++ b/lib/android/views/android_settings_page.dart @@ -168,7 +168,8 @@ class _AndroidSettingsPageState extends ConsumerState { SwitchListTile( title: const Text('Silence NFC sounds'), subtitle: nfcSilenceSounds - ? const Text('No sounds will be played on NFC tap') + ? const Text( + 'No sounds will be played on NFC tap') : const Text('Sound will play on NFC tap'), value: nfcSilenceSounds, key: keys.nfcSilenceSoundsSettings, diff --git a/lib/oath/state.dart b/lib/oath/state.dart index ef133c9d..b3824dc8 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -202,4 +202,4 @@ class FilteredCredentialsNotifier extends StateNotifier> { .where((pair) => pair.credential.issuer != '_hidden') .toList(), ); -} \ No newline at end of file +} diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 580dbf88..e6d877ac 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -39,7 +39,6 @@ class OathScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ref.watch(oathStateProvider(devicePath)).when( loading: () => MessagePage( title: Text(AppLocalizations.of(context)!.oath_authenticator), diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 72878ba1..80f3bd29 100755 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -34,7 +34,6 @@ class SettingsPage extends ConsumerWidget { final themeMode = ref.watch(themeModeProvider); final theme = Theme.of(context); - return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.general_settings), child: Theme( From 1771fc683a7fb5cf8f585ee49167f7f764528bbc Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 15:25:27 +0100 Subject: [PATCH 37/39] updated year in license headers --- lib/oath/keys.dart | 2 +- lib/oath/views/account_view.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/oath/keys.dart b/lib/oath/keys.dart index 5cacfc02..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. diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 2f28409b..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. From 573e08b06669f6db06946dd0ea35dd522a42765f Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 16:12:46 +0100 Subject: [PATCH 38/39] don't pass unmodifiable list to super --- lib/android/oath/state.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 57f0071a..b23db3ea 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -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 From bf199888a2cceb79e83b5e5d30b496bf3ce56077 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 28 Feb 2023 17:07:37 +0100 Subject: [PATCH 39/39] update year in license --- lib/android/oath/state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index b23db3ea..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.