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: