update file history when changing database name #57

This commit is contained in:
Herbert Poul
2020-04-03 14:33:08 +02:00
parent 39cd5254fb
commit 45ca79d4ce
8 changed files with 276 additions and 176 deletions

View File

@ -11,12 +11,15 @@ import 'package:built_value/serializer.dart';
import 'package:built_value/standard_json_plugin.dart';
import 'package:clock/clock.dart';
import 'package:flutter/material.dart' show Color;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:simple_json_persistence/simple_json_persistence.dart';
import 'package:uuid/uuid.dart';
part 'app_data.g.dart';
final _logger = Logger('app_data');
enum OpenedFilesSourceType { Local, Url, CloudStorage }
class SimpleEnumSerializer<T> extends PrimitiveSerializer<T> {
@ -127,10 +130,14 @@ abstract class OpenedFile implements Built<OpenedFile, OpenedFileBuilder> {
File(sourcePath),
macOsSecureBookmark: macOsSecureBookmark,
uuid: uuid ?? AppDataBloc.createUuid(),
databaseName: name,
);
case OpenedFilesSourceType.Url:
return FileSourceUrl(Uri.parse(sourcePath),
uuid: uuid ?? AppDataBloc.createUuid());
return FileSourceUrl(
Uri.parse(sourcePath),
uuid: uuid ?? AppDataBloc.createUuid(),
databaseName: name,
);
case OpenedFilesSourceType.CloudStorage:
final sourceInfo = json.decode(sourcePath) as Map<String, dynamic>;
final storageId = sourceInfo[SOURCE_CLOUD_STORAGE_ID] as String;
@ -139,9 +146,10 @@ abstract class OpenedFile implements Built<OpenedFile, OpenedFileBuilder> {
throw StateError('Invalid cloud storage provider id $storageId');
}
return provider.toFileSource(
(sourceInfo[SOURCE_CLOUD_STORAGE_DATA] as Map)
.cast<String, String>(),
uuid: uuid ?? AppDataBloc.createUuid());
(sourceInfo[SOURCE_CLOUD_STORAGE_DATA] as Map).cast<String, String>(),
uuid: uuid ?? AppDataBloc.createUuid(),
databaseName: name,
);
}
throw ArgumentError.value(sourceType, 'sourceType', 'Unsupported value.');
}
@ -237,6 +245,7 @@ class AppDataBloc {
final colorCode = recentFile?.colorCode;
final openedFile = OpenedFile.fromFileSource(
file, name, (b) => b..colorCode = colorCode);
_logger.finest('openedFile: $openedFile');
// TODO remove potential old storages?
b.previousFiles.removeWhere((file) => file.isSameFileAs(openedFile));
b.previousFiles.add(openedFile);

View File

@ -39,11 +39,11 @@ class FileContent {
}
abstract class FileSource {
FileSource(
{@required this.databaseName,
@required this.uuid,
FileContent initialCachedContent})
: _cached = initialCachedContent;
FileSource({
@required this.databaseName,
@required this.uuid,
FileContent initialCachedContent,
}) : _cached = initialCachedContent;
FileContent _cached;
@ -74,6 +74,8 @@ abstract class FileSource {
String get typeDebug => runtimeType.toString();
FileSource copyWithDatabaseName(String databaseName);
@protected
Future<FileContent> load();
@ -182,6 +184,14 @@ class FileSourceLocal extends FileSource {
@override
IconData get displayIcon => FontAwesomeIcons.hdd;
@override
FileSource copyWithDatabaseName(String databaseName) => FileSourceLocal(
file,
databaseName: databaseName,
uuid: uuid,
macOsSecureBookmark: macOsSecureBookmark,
);
}
class FileSourceUrl extends FileSource {
@ -217,6 +227,13 @@ class FileSourceUrl extends FileSource {
@override
IconData get displayIcon => FontAwesomeIcons.externalLinkAlt;
@override
FileSource copyWithDatabaseName(String databaseName) => FileSourceUrl(
url,
uuid: uuid,
databaseName: databaseName,
);
}
class FileSourceCloudStorage extends FileSource {
@ -258,6 +275,16 @@ class FileSourceCloudStorage extends FileSource {
@override
IconData get displayIcon => provider.displayIcon;
@override
FileSource copyWithDatabaseName(String databaseName) =>
FileSourceCloudStorage(
provider: provider,
fileInfo: fileInfo,
uuid: uuid,
databaseName: databaseName,
initialCachedContent: _cached,
);
}
class FileExistsException extends KdbxException {}
@ -359,6 +386,26 @@ class KdbxOpenedFile {
final KdbxFile kdbxFile;
}
class OpenedKdbxFiles {
OpenedKdbxFiles(Map<FileSource, KdbxOpenedFile> files)
: _files = Map.unmodifiable(files);
final Map<FileSource, KdbxOpenedFile> _files;
int get length => _files.length;
// bool get isNotEmpty => _files.isNotEmpty;
KdbxOpenedFile operator [](FileSource fileSource) => _files[fileSource];
Iterable<MapEntry<FileSource, KdbxOpenedFile>> get entries => _files.entries;
Iterable<KdbxOpenedFile> get values => _files.values;
bool containsKey(FileSource file) => _files.containsKey(file);
// Map<K2, V2> map<K2, V2>(
// MapEntry<K2, V2> Function(FileSource key, KdbxOpenedFile value) f) =>
// _files.map(f);
}
class KdbxBloc {
KdbxBloc({
@required this.env,
@ -383,7 +430,7 @@ class KdbxBloc {
final KdbxFormat kdbxFormat = KdbxFormat(FlutterArgon2());
final _openedFiles =
BehaviorSubject<Map<FileSource, KdbxOpenedFile>>.seeded({});
BehaviorSubject<OpenedKdbxFiles>.seeded(OpenedKdbxFiles({}));
Map<KdbxFile, KdbxOpenedFile> _openedFilesByKdbxFile;
final _openedFilesQuickUnlock = <FileSource>{};
@ -391,11 +438,10 @@ class KdbxBloc {
_openedFiles.value.entries
.map((entry) => MapEntry(entry.key, entry.value.kdbxFile));
Map<FileSource, KdbxOpenedFile> get openedFiles => _openedFiles.value;
OpenedKdbxFiles get openedFiles => _openedFiles.value;
List<KdbxFile> get openedFilesKdbx =>
_openedFiles.value.values.map((value) => value.kdbxFile).toList();
ValueStream<Map<FileSource, KdbxOpenedFile>> get openedFilesChanged =>
_openedFiles.stream;
ValueStream<OpenedKdbxFiles> get openedFilesChanged => _openedFiles.stream;
Future<int> _quickUnlockCheckRunning;
@ -422,10 +468,10 @@ class KdbxBloc {
openedFile: updatedFile,
kdbxFile: file.kdbxFile,
);
_openedFiles.value = {
..._openedFiles.value,
_openedFiles.value = OpenedKdbxFiles({
..._openedFiles.value._files,
file.fileSource: newFile,
};
});
_logger.info('new values: ${_openedFiles.value}');
return newFile;
}
@ -452,14 +498,14 @@ class KdbxBloc {
final kdbxFile = kdbxReadFile.file;
final openedFile = await appDataBloc.openedFile(file,
name: kdbxFile.body.meta.databaseName.get());
_openedFiles.value = {
..._openedFiles.value,
_openedFiles.value = OpenedKdbxFiles({
..._openedFiles.value._files,
file: KdbxOpenedFile(
fileSource: file,
openedFile: openedFile,
kdbxFile: kdbxFile,
)
};
});
analytics.events.trackOpenFile(type: file.typeDebug);
analytics.events.trackOpenFile2(
generator: kdbxFile.body.meta.generator.get() ?? 'NULL',
@ -535,7 +581,8 @@ class KdbxBloc {
_logger.fine('Close file.');
analytics.events.trackCloseFile();
final fileSource = fileForKdbxFile(file).fileSource;
_openedFiles.value = Map.from(_openedFiles.value)..remove(fileSource);
_openedFiles.value = OpenedKdbxFiles(
Map.from(_openedFiles.value._files)..remove(fileSource));
if (_openedFilesQuickUnlock.remove(fileSource)) {
_logger.fine('file was in quick unlock. need to persist it.');
await _updateQuickUnlockStore();
@ -547,7 +594,7 @@ class KdbxBloc {
void closeAllFiles() {
_logger.finer('Closing all files, clearing quick unlock.');
analytics.events.trackCloseAllFiles(count: _openedFiles.value?.length);
_openedFiles.value = {};
_openedFiles.value = OpenedKdbxFiles({});
// clear all quick unlock data.
_openedFilesQuickUnlock.clear();
quickUnlockStorage.updateQuickUnlockFile({});
@ -676,10 +723,11 @@ class KdbxBloc {
openedFile: newOpenedFile,
kdbxFile: oldFile.kdbxFile,
);
_openedFiles.value = {
..._openedFiles.value,
_openedFiles.value = OpenedKdbxFiles({
...Map.fromEntries(_openedFiles.value._files.entries
.where((entry) => entry.key != oldSource)),
newFile.fileSource: newFile,
}..removeWhere((key, value) => key == oldSource);
});
await _updateQuickUnlockStore();
return newFile;
}

View File

@ -206,13 +206,19 @@ abstract class CloudStorageProvider {
String displayPath(Map<String, String> fileInfo) =>
CloudStorageEntity.fromSimpleFileInfo(fileInfo).pathOrBaseName;
FileSource toFileSource(Map<String, String> fileInfo,
{@required String uuid, FileContent initialCachedContent}) =>
FileSource toFileSource(
Map<String, String> fileInfo, {
@required String uuid,
FileContent initialCachedContent,
String databaseName,
}) =>
FileSourceCloudStorage(
provider: this,
fileInfo: fileInfo,
uuid: uuid,
initialCachedContent: initialCachedContent);
provider: this,
fileInfo: fileInfo,
uuid: uuid,
databaseName: databaseName,
initialCachedContent: initialCachedContent,
);
Future<FileContent> loadFile(Map<String, String> fileInfo) =>
loadEntity(CloudStorageEntity.fromSimpleFileInfo(fileInfo));

View File

@ -133,6 +133,10 @@ class _AuthPassAppState extends State<AuthPassApp> with StreamSubscriberMixin {
updateShouldNotify: (a, b) => true,
initialData: _deps.kdbxBloc,
),
StreamProvider<OpenedKdbxFiles>.value(
value: _deps.kdbxBloc.openedFilesChanged,
initialData: _deps.kdbxBloc.openedFilesChanged.value,
)
],
child: MaterialApp(
navigatorObservers: [AnalyticsNavigatorObserver(_deps.analytics)],

View File

@ -89,7 +89,7 @@ class AuthPassAboutDialog extends StatelessWidget {
static Iterable<PopupMenuEntry<VoidCallback>> createDefaultPopupMenuItems(
BuildContext context) {
final openedFiles =
Provider.of<KdbxBloc>(context, listen: false)?.openedFiles?.values;
Provider.of<OpenedKdbxFiles>(context, listen: false).values;
return [
PopupMenuItem(
child: const ListTile(

View File

@ -38,9 +38,12 @@ class ManageFileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// when changing the database name, we have to refresh the file source.
final kdbxBloc = Provider.of<KdbxBloc>(context);
final currentFileSource = kdbxBloc.fileForFileSource(fileSource).fileSource;
return Scaffold(
appBar: AppBar(
title: Text(fileSource.displayName),
title: Text(currentFileSource.displayName),
),
body: ManageFile(fileSource: fileSource),
);
@ -106,7 +109,7 @@ class _ManageFileState extends State<ManageFile> with FutureTaskStateMixin {
children: <Widget>[
ListTile(
title: Text(databaseName),
trailing: Icon(Icons.edit),
trailing: const Icon(Icons.edit),
onTap: () async {
final newName = await SimplePromptDialog(
title: 'Enter database name',
@ -115,6 +118,14 @@ class _ManageFileState extends State<ManageFile> with FutureTaskStateMixin {
setState(() {
_file.kdbxFile.body.meta.databaseName.set(newName);
});
await asyncRunTask((progress) async {
await Future<int>.delayed(
const Duration(milliseconds: 100));
await _kdbxBloc.saveAs(
_file,
_file.fileSource.copyWithDatabaseName(newName),
);
}, label: 'Saving');
},
),
ListTile(

View File

@ -354,9 +354,11 @@ class _PasswordListContentState extends State<PasswordListContent>
...AuthPassAboutDialog.createDefaultPopupMenuItems(context),
PopupMenuItem(
value: () {
Provider.of<KdbxBloc>(context).closeAllFiles();
Navigator.of(context)
.pushAndRemoveUntil(SelectFileScreen.route(), (_) => false);
Provider.of<KdbxBloc>(context, listen: false).closeAllFiles();
Navigator.of(context, rootNavigator: true).pushAndRemoveUntil(
SelectFileScreen.route(skipQuickUnlock: true),
(_) => false,
);
},
child: ListTile(
leading: Icon(Icons.exit_to_app),

View File

@ -33,11 +33,20 @@ import '../../theme.dart';
final _logger = Logger('authpass.select_file_screen');
class SelectFileScreen extends StatelessWidget {
static Route<Object> route() => MaterialPageRoute(
const SelectFileScreen({Key key, this.skipQuickUnlock = false})
: assert(skipQuickUnlock != null),
super(key: key);
static Route<Object> route({bool skipQuickUnlock = false}) =>
MaterialPageRoute(
settings: const RouteSettings(name: '/selectFile'),
builder: (context) => SelectFileScreen(),
builder: (context) => SelectFileScreen(
skipQuickUnlock: skipQuickUnlock,
),
);
final bool skipQuickUnlock;
@override
Widget build(BuildContext context) {
Provider.of<Analytics>(context).events.trackLaunch();
@ -53,7 +62,9 @@ class SelectFileScreen extends StatelessWidget {
value: cloudBloc,
child: Container(
alignment: Alignment.center,
child: const SelectFileWidget(),
child: SelectFileWidget(
skipQuickUnlock: skipQuickUnlock,
),
),
),
);
@ -128,8 +139,11 @@ class ProgressOverlay extends StatelessWidget {
class SelectFileWidget extends StatefulWidget {
const SelectFileWidget({
Key key,
this.skipQuickUnlock = false,
}) : super(key: key);
final bool skipQuickUnlock;
@override
_SelectFileWidgetState createState() => _SelectFileWidgetState();
}
@ -142,10 +156,12 @@ class _SelectFileWidgetState extends State<SelectFileWidget>
@override
void didChangeDependencies() {
super.didChangeDependencies();
_logger.finer('didChangeDependencies');
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_checkQuickUnlock();
});
_logger.finer('didChangeDependencies ${widget.skipQuickUnlock}');
if (!widget.skipQuickUnlock) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_checkQuickUnlock();
});
}
// Future<int>.delayed(const Duration(seconds: 5))
// .then((value) => _checkQuickUnlock());
// _checkQuickUnlock();
@ -155,7 +171,7 @@ class _SelectFileWidgetState extends State<SelectFileWidget>
void didUpdateWidget(covariant SelectFileWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_logger.finer('didUpdateWidget --- ${oldWidget != widget}');
if (oldWidget != widget) {
if (oldWidget != widget && !widget.skipQuickUnlock) {
_checkQuickUnlock();
}
}
@ -210,147 +226,151 @@ class _SelectFileWidgetState extends State<SelectFileWidget>
final cloudStorageBloc = Provider.of<CloudStorageBloc>(context);
return ProgressOverlay(
task: task,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
const Text('Please select a KeePass (.kdbx) file.'),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 16,
runSpacing: 16,
children: <Widget>[
SelectFileAction(
icon: FontAwesomeIcons.hdd,
label: 'Open\nLocal File',
onPressed: () async {
if (Platform.isIOS || Platform.isAndroid) {
final path =
await FilePicker.getFilePath(type: FileType.any);
if (path != null) {
await Navigator.of(context).push(CredentialsScreen.route(
FileSourceLocal(File(path),
uuid: AppDataBloc.createUuid())));
}
} else {
showOpenPanel((result, paths) async {
if (result == FileChooserResult.ok) {
String macOsBookmark;
if (Platform.isMacOS) {
macOsBookmark =
await SecureBookmarks().bookmark(File(paths[0]));
}
await Navigator.of(context)
.push(CredentialsScreen.route(FileSourceLocal(
File(paths[0]),
uuid: AppDataBloc.createUuid(),
macOsSecureBookmark: macOsBookmark,
)));
}
});
}
},
),
...cloudStorageBloc.availableCloudStorage.map(
(cs) => SelectFileAction(
icon: cs.displayIcon,
label: 'Load from ${cs.displayName}',
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
const SizedBox(height: 16),
const Text('Please select a KeePass (.kdbx) file.'),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 16,
runSpacing: 16,
children: <Widget>[
SelectFileAction(
icon: FontAwesomeIcons.hdd,
label: 'Open\nLocal File',
onPressed: () async {
final source = await Navigator.of(context).push(
CloudStorageSelector.route(
cs, CloudStorageOpenConfig()));
if (source != null) {
await Navigator.of(context)
.push(CredentialsScreen.route(source.fileSource));
if (Platform.isIOS || Platform.isAndroid) {
final path =
await FilePicker.getFilePath(type: FileType.any);
if (path != null) {
await Navigator.of(context).push(
CredentialsScreen.route(FileSourceLocal(File(path),
uuid: AppDataBloc.createUuid())));
}
} else {
showOpenPanel((result, paths) async {
if (result == FileChooserResult.ok) {
String macOsBookmark;
if (Platform.isMacOS) {
macOsBookmark = await SecureBookmarks()
.bookmark(File(paths[0]));
}
await Navigator.of(context)
.push(CredentialsScreen.route(FileSourceLocal(
File(paths[0]),
uuid: AppDataBloc.createUuid(),
macOsSecureBookmark: macOsBookmark,
)));
}
});
}
},
),
),
],
),
const SizedBox(
height: 4,
),
IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: LinkButton(
onPressed: () async {
final source = await showDialog<FileSourceUrl>(
context: context,
builder: (context) => SelectUrlDialog());
if (source != null) {
_loadAndGoToCredentials(source);
}
},
child: const Text(
'Download from URL',
textAlign: TextAlign.right,
...cloudStorageBloc.availableCloudStorage.map(
(cs) => SelectFileAction(
icon: cs.displayIcon,
label: 'Load from ${cs.displayName}',
onPressed: () async {
final source = await Navigator.of(context).push(
CloudStorageSelector.route(
cs, CloudStorageOpenConfig()));
if (source != null) {
await Navigator.of(context)
.push(CredentialsScreen.route(source.fileSource));
}
},
),
),
],
),
const SizedBox(
height: 4,
),
IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: LinkButton(
onPressed: () async {
final source = await showDialog<FileSourceUrl>(
context: context,
builder: (context) => SelectUrlDialog());
if (source != null) {
_loadAndGoToCredentials(source);
}
},
child: const Text(
'Download from URL',
textAlign: TextAlign.right,
),
),
),
),
),
VerticalDivider(
indent: 8,
endIndent: 8,
color: Theme.of(context).primaryColor,
),
Expanded(
child: LinkButton(
onPressed: () {
Navigator.of(context).push(CreateFile.route());
},
icon: Icon(Icons.create_new_folder),
child: const Expanded(
child: Text(
'New to KeePass?\nCreate New Password Database',
softWrap: true)),
VerticalDivider(
indent: 8,
endIndent: 8,
color: Theme.of(context).primaryColor,
),
)
],
),
),
const SizedBox(height: 8),
IntrinsicWidth(
stepWidth: 100,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'Last opened files:',
style: Theme.of(context)
.textTheme
.bodyText2
.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
...ListTile.divideTiles(
context: context,
tiles: appData?.previousFiles?.reversed?.take(5)?.map(
(f) => OpenedFileTile(
openedFile: f.toFileSource(cloudStorageBloc),
onPressed: () {
final source =
f.toFileSource(cloudStorageBloc);
_loadAndGoToCredentials(source);
},
),
) ??
[const Text('No files have been opened yet.')]),
Expanded(
child: LinkButton(
onPressed: () {
Navigator.of(context).push(CreateFile.route());
},
icon: Icon(Icons.create_new_folder),
child: const Expanded(
child: Text(
'New to KeePass?\nCreate New Password Database',
softWrap: true)),
),
)
],
),
),
),
],
const SizedBox(height: 8),
IntrinsicWidth(
stepWidth: 100,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'Last opened files:',
style: Theme.of(context)
.textTheme
.bodyText2
.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
...ListTile.divideTiles(
context: context,
tiles: appData?.previousFiles?.reversed?.take(5)?.map(
(f) => OpenedFileTile(
openedFile:
f.toFileSource(cloudStorageBloc),
onPressed: () {
final source =
f.toFileSource(cloudStorageBloc);
_loadAndGoToCredentials(source);
},
),
) ??
[const Text('No files have been opened yet.')]),
],
),
),
),
],
),
),
);
}
@ -713,7 +733,7 @@ class _CredentialsScreenState extends State<CredentialsScreen> {
Future<void> _tryUnlock() async {
if (_formKey.currentState.validate()) {
final kdbxBloc = Provider.of<Deps>(context).kdbxBloc;
final kdbxBloc = Provider.of<Deps>(context, listen: false).kdbxBloc;
final pw = _controller.text;
final keyFileContents = await _keyFile?.readAsBytes();
try {