mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 00:12:09 +03:00
add support for custom account icons
This commit is contained in:
parent
494e99b36f
commit
a357b206fe
@ -207,8 +207,8 @@ class AboutPage extends ConsumerWidget {
|
||||
dialogTitle: 'Choose icon pack');
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final importStatus = await ref
|
||||
.read(issuerIconProvider)
|
||||
.importPack(result.paths.first!);
|
||||
.read(accountIconProvider)
|
||||
.importIconPack(result.paths.first!);
|
||||
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
|
@ -66,6 +66,10 @@ class DeleteIntent extends Intent {
|
||||
const DeleteIntent();
|
||||
}
|
||||
|
||||
class ChangeAccountIconIntent extends Intent {
|
||||
const ChangeAccountIconIntent();
|
||||
}
|
||||
|
||||
final ctrlOrCmd =
|
||||
Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control;
|
||||
|
||||
|
@ -12,25 +12,25 @@ 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');
|
||||
final _log = Logger('account_icon_provider');
|
||||
|
||||
class IssuerIcon {
|
||||
class IconPackIcon {
|
||||
final String filename;
|
||||
final String? category;
|
||||
final List<String> issuer;
|
||||
|
||||
const IssuerIcon(
|
||||
const IconPackIcon(
|
||||
{required this.filename, required this.category, required this.issuer});
|
||||
}
|
||||
|
||||
class IssuerIconPack {
|
||||
class IconPack {
|
||||
final String uuid;
|
||||
final String name;
|
||||
final int version;
|
||||
final Directory directory;
|
||||
final List<IssuerIcon> icons;
|
||||
final List<IconPackIcon> icons;
|
||||
|
||||
const IssuerIconPack(
|
||||
const IconPack(
|
||||
{required this.uuid,
|
||||
required this.name,
|
||||
required this.version,
|
||||
@ -46,20 +46,25 @@ class 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}account_icons_cache${Platform.pathSeparator}');
|
||||
}
|
||||
|
||||
File _cachedFile(String fileName) => File('${cacheDirectory.path}${fileName}_cached');
|
||||
|
||||
Future<Uint8List?> getCachedFileData(String fileName) async {
|
||||
File? getFile(String fileName) {
|
||||
final file = _cachedFile(fileName);
|
||||
final exists = await file.exists();
|
||||
if (exists) {
|
||||
final exists = file.existsSync();
|
||||
return exists ? file : null;
|
||||
}
|
||||
|
||||
Future<Uint8List?> getCachedFileData(String fileName) async {
|
||||
final file = getFile(fileName);
|
||||
if (file != null) {
|
||||
_log.debug('File $fileName exists in cache');
|
||||
} else {
|
||||
_log.debug('File $fileName does not exist in cache');
|
||||
}
|
||||
return (exists) ? file.readAsBytes() : null;
|
||||
return file?.readAsBytes();
|
||||
}
|
||||
|
||||
Future<void> writeFileData(String fileName, Uint8List data) async {
|
||||
@ -111,15 +116,15 @@ class CachingFileLoader extends BytesLoader {
|
||||
}
|
||||
}
|
||||
|
||||
class IssuerIconProvider {
|
||||
class AccountIconProvider extends ChangeNotifier {
|
||||
final FileSystemCache _cache;
|
||||
late IssuerIconPack _issuerIconPack;
|
||||
late IconPack _iconPack;
|
||||
|
||||
IssuerIconProvider(this._cache) {
|
||||
AccountIconProvider(this._cache) {
|
||||
_cache.initialize();
|
||||
}
|
||||
|
||||
void readPack(String relativePackPath) async {
|
||||
void readIconPack(String relativePackPath) async {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final packDirectory = Directory(
|
||||
'${documentsDirectory.path}${Platform.pathSeparator}$relativePackPath${Platform.pathSeparator}');
|
||||
@ -129,7 +134,7 @@ class IssuerIconProvider {
|
||||
|
||||
if (!await packFile.exists()) {
|
||||
_log.debug('Failed to find icons pack ${packFile.path}');
|
||||
_issuerIconPack = IssuerIconPack(
|
||||
_iconPack = IconPack(
|
||||
uuid: '',
|
||||
name: '',
|
||||
version: 0,
|
||||
@ -141,19 +146,19 @@ class IssuerIconProvider {
|
||||
var packContent = await packFile.readAsString();
|
||||
Map<String, dynamic> pack = const JsonDecoder().convert(packContent);
|
||||
|
||||
final icons = List<IssuerIcon>.from(pack['icons'].map((icon) => IssuerIcon(
|
||||
final icons = List<IconPackIcon>.from(pack['icons'].map((icon) => IconPackIcon(
|
||||
filename: icon['filename'],
|
||||
category: icon['category'],
|
||||
issuer: List<String>.from(icon['issuer']))));
|
||||
|
||||
_issuerIconPack = IssuerIconPack(
|
||||
_iconPack = IconPack(
|
||||
uuid: pack['uuid'],
|
||||
name: pack['name'],
|
||||
version: pack['version'],
|
||||
directory: packDirectory,
|
||||
icons: icons);
|
||||
_log.debug(
|
||||
'Parsed ${_issuerIconPack.name} with ${_issuerIconPack.icons.length} icons');
|
||||
'Parsed ${_iconPack.name} with ${_iconPack.icons.length} icons');
|
||||
}
|
||||
|
||||
Future<bool> _cleanTempDirectory(Directory tempDirectory) async {
|
||||
@ -169,7 +174,7 @@ class IssuerIconProvider {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> importPack(String filePath) async {
|
||||
Future<bool> importIconPack(String filePath) async {
|
||||
|
||||
final packFile = File(filePath);
|
||||
if (!await packFile.exists()) {
|
||||
@ -218,34 +223,62 @@ class IssuerIconProvider {
|
||||
|
||||
// remove old icons pack and icon pack cache
|
||||
final packDirectory = Directory(
|
||||
'${documentsDirectory.path}${Platform.pathSeparator}issuer_icons${Platform.pathSeparator}');
|
||||
'${documentsDirectory.path}${Platform.pathSeparator}default_icon_pack${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}');
|
||||
final packCacheDirectory = _cache.cacheDirectory;
|
||||
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');
|
||||
readIconPack('default_icon_pack');
|
||||
|
||||
notifyListeners();
|
||||
|
||||
await _cleanTempDirectory(tempDirectory);
|
||||
return true;
|
||||
}
|
||||
|
||||
VectorGraphic? issuerVectorGraphic(String issuer, Widget placeHolder) {
|
||||
final matching = _issuerIconPack.icons
|
||||
Future<bool> importCustomAccountImage(String accountName, String? issuer, String filePath) async {
|
||||
|
||||
final requestedFile = File(filePath);
|
||||
final customAccountImageFilename = '${_cache.cacheDirectory.path}${_getCustomAccountImageFilename(accountName, issuer)}_cached';
|
||||
|
||||
_log.debug('Copying custom image file $customAccountImageFilename');
|
||||
final customAccountImageFile = await requestedFile.copy(customAccountImageFilename);
|
||||
|
||||
await FileImage(customAccountImageFile).evict();
|
||||
notifyListeners();
|
||||
|
||||
return await customAccountImageFile.exists();
|
||||
}
|
||||
|
||||
String _getCustomAccountImageFilename(String accountName, String? issuer) => base64Encode(utf8.encode('$accountName:$issuer'));
|
||||
|
||||
Widget? getAccountIcon(String accountName, String? issuer, Widget placeHolder) {
|
||||
|
||||
final customAccountImageFileName = _getCustomAccountImageFilename(accountName, issuer);
|
||||
|
||||
_log.info('Checking if custom account image for $accountName:$issuer '
|
||||
'($customAccountImageFileName) exists...');
|
||||
|
||||
final customFile = _cache.getFile(customAccountImageFileName);
|
||||
if (customFile != null) {
|
||||
_log.debug('Using custom account image for $accountName:$issuer');
|
||||
return Image.file(customFile, filterQuality: FilterQuality.medium);
|
||||
}
|
||||
|
||||
final matching = _iconPack.icons
|
||||
.where((element) => element.issuer.any((element) => element == issuer));
|
||||
final issuerImageFile = matching.isNotEmpty
|
||||
? File('${_issuerIconPack.directory.path}${matching.first.filename}')
|
||||
? File('${_iconPack.directory.path}${matching.first.filename}')
|
||||
: null;
|
||||
return issuerImageFile != null && issuerImageFile.existsSync()
|
||||
? VectorGraphic(
|
||||
@ -257,15 +290,4 @@ class IssuerIconProvider {
|
||||
)
|
||||
: 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;
|
||||
}
|
||||
}
|
@ -25,7 +25,7 @@ import '../app/models.dart';
|
||||
import '../app/state.dart';
|
||||
import '../core/models.dart';
|
||||
import '../core/state.dart';
|
||||
import 'issuer_icon_provider.dart';
|
||||
import 'account_icon_provider.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final searchProvider =
|
||||
@ -203,5 +203,5 @@ class FilteredCredentialsNotifier extends StateNotifier<List<OathPair>> {
|
||||
);
|
||||
}
|
||||
|
||||
final issuerIconProvider = Provider<IssuerIconProvider>(
|
||||
(ref) => IssuerIconProvider(FileSystemCache()));
|
||||
final accountIconProvider = ChangeNotifierProvider<AccountIconProvider>(
|
||||
(ref) => AccountIconProvider(FileSystemCache()));
|
@ -20,6 +20,7 @@ import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:yubico_authenticator/oath/account_icon_provider.dart';
|
||||
|
||||
import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
@ -65,6 +66,18 @@ class AccountHelper {
|
||||
final appLocalizations = AppLocalizations.of(_context)!;
|
||||
final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C';
|
||||
return [
|
||||
MenuAction(
|
||||
text: 'Set custom icon',
|
||||
icon: _ref.watch(accountIconProvider).getAccountIcon(
|
||||
credential.name,
|
||||
credential.issuer,
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
)) ??
|
||||
const Icon(Icons.image),
|
||||
intent: const ChangeAccountIconIntent(),
|
||||
trailing: shortcut,
|
||||
),
|
||||
MenuAction(
|
||||
text: appLocalizations.oath_copy_to_clipboard,
|
||||
icon: const Icon(Icons.copy),
|
||||
|
@ -205,8 +205,8 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
||||
height: 40,
|
||||
child: showAvatar
|
||||
? ref
|
||||
.read(issuerIconProvider)
|
||||
.issuerVectorGraphic(credential.issuer ?? '', circleAvatar) ??
|
||||
.watch(accountIconProvider)
|
||||
.getAccountIcon(credential.name, credential.issuer, circleAvatar) ??
|
||||
circleAvatar
|
||||
: null),
|
||||
title: Text(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
@ -63,6 +64,31 @@ Widget registerOathActions(
|
||||
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
|
||||
return null;
|
||||
}),
|
||||
ChangeAccountIconIntent: CallbackAction<ChangeAccountIconIntent>(onInvoke: (_) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowedExtensions: ['jpg', 'png'],
|
||||
type: FileType.custom,
|
||||
allowMultiple: false,
|
||||
lockParentWindow: true,
|
||||
dialogTitle: 'Choose custom image');
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final importStatus = await ref
|
||||
.read(accountIconProvider)
|
||||
.importCustomAccountImage(credential.name, credential.issuer, result.paths.first!);
|
||||
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
if (importStatus) {
|
||||
showMessage(context, 'Custom image imported');
|
||||
} else {
|
||||
showMessage(context, 'Error importing custom image');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
...actions,
|
||||
},
|
||||
child: Builder(builder: builder),
|
||||
|
@ -40,7 +40,7 @@ class OathScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
ref.read(issuerIconProvider).readPack('issuer_icons');
|
||||
ref.read(accountIconProvider).readIconPack('default_icon_pack');
|
||||
return ref.watch(oathStateProvider(devicePath)).when(
|
||||
loading: () => MessagePage(
|
||||
title: Text(AppLocalizations.of(context)!.oath_authenticator),
|
||||
|
Loading…
Reference in New Issue
Block a user