diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index db55acda1..b337bfa6d 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:photos/models/backup_status.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_load_result.dart'; +import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/location.dart'; import 'package:sqflite/sqflite.dart'; @@ -51,12 +52,22 @@ class FilesDB { static final columnThumbnailDecryptionHeader = 'thumbnail_decryption_header'; static final columnMetadataDecryptionHeader = 'metadata_decryption_header'; + // MMD -> Magic Metadata + static final columnMMdEncodedJson = 'mmd_encoded_json'; + static final columnMMdVersion = 'mmd_ver'; + + // part of magic metadata + // Only parse & store selected fields from JSON in separate columns if + // we need to write query based on that field + static final columnMMdVisibility = 'mmd_visibility'; + static final initializationScript = [...createTable(table)]; static final migrationScripts = [ ...alterDeviceFolderToAllowNULL(), ...alterTimestampColumnTypes(), ...addIndices(), ...addMetadataColumns(), + ...addMagicMetadataColumns(), ]; final dbConfig = MigrationConfig( @@ -227,6 +238,20 @@ class FilesDB { ]; } + static List addMagicMetadataColumns() { + return [ + ''' + ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}'; + ''', + ''' + ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0; + ''', + ''' + ALTER TABLE $table ADD COLUMN $columnMMdVisibility INTEGER DEFAULT $kVisibilityVisible; + ''' + ]; + } + Future clearTable() async { final db = await instance.database; await db.delete(table); @@ -332,20 +357,22 @@ class FilesDB { } Future getAllUploadedFiles(int startTime, int endTime, - int ownerID, {int limit, bool asc}) async { + int ownerID, {int limit, bool asc, int visibility = kVisibilityVisible}) async { final db = await instance.database; final order = (asc ?? false ? 'ASC' : 'DESC'); final results = await db.query( table, where: - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnOwnerID = ? AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)', - whereArgs: [startTime, endTime, ownerID], + '$columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnOwnerID = ? AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)' + ' AND $columnMMdVisibility = ?', + whereArgs: [startTime, endTime, ownerID, visibility], orderBy: '$columnCreationTime ' + order + ', $columnModificationTime ' + order, limit: limit, ); final files = _convertToFiles(results); - return FileLoadResult(files, files.length == limit); + List deduplicatedFiles = _deduplicatedFiles(files); + return FileLoadResult(deduplicatedFiles, files.length == limit); } Future getAllLocalAndUploadedFiles(int startTime, int endTime, int ownerID, @@ -355,8 +382,9 @@ class FilesDB { final results = await db.query( table, where: - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', - whereArgs: [startTime, endTime, ownerID], + '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' + ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', + whereArgs: [startTime, endTime, ownerID, kVisibilityVisible], orderBy: '$columnCreationTime ' + order + ', $columnModificationTime ' + order, limit: limit, @@ -378,14 +406,20 @@ class FilesDB { final results = await db.query( table, where: - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND (($columnLocalID IS NOT NULL AND $columnDeviceFolder IN ($inParam)) OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', - whereArgs: [startTime, endTime, ownerID], + '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' + 'AND (($columnLocalID IS NOT NULL AND $columnDeviceFolder IN ($inParam)) OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', + whereArgs: [startTime, endTime, ownerID, kVisibilityVisible], orderBy: '$columnCreationTime ' + order + ', $columnModificationTime ' + order, limit: limit, ); - final uploadedFileIDs = {}; final files = _convertToFiles(results); + List deduplicatedFiles = _deduplicatedFiles(files); + return FileLoadResult(deduplicatedFiles, files.length == limit); + } + + List _deduplicatedFiles(List files) { + final uploadedFileIDs = {}; final List deduplicatedFiles = []; for (final file in files) { final id = file.uploadedFileID; @@ -395,7 +429,7 @@ class FilesDB { uploadedFileIDs.add(id); deduplicatedFiles.add(file); } - return FileLoadResult(deduplicatedFiles, files.length == limit); + return deduplicatedFiles; } Future getFilesInCollection( @@ -877,6 +911,10 @@ class FilesDB { row[columnExif] = file.exif; row[columnHash] = file.hash; row[columnMetadataVersion] = file.metadataVersion; + row[columnMMdVersion] = file.mMdVersion ?? 0; + row[columnMMdEncodedJson] = file.mMdEncodedJson ?? '{}'; + row[columnMMdVisibility] = + file.magicMetadata?.visibility ?? kVisibilityVisible; return row; } @@ -903,6 +941,11 @@ class FilesDB { row[columnExif] = file.exif; row[columnHash] = file.hash; row[columnMetadataVersion] = file.metadataVersion; + + row[columnMMdVersion] = file.mMdVersion ?? 0; + row[columnMMdEncodedJson] == file.mMdEncodedJson ?? '{}'; + row[columnMMdVisibility] = + file.magicMetadata?.visibility ?? kVisibilityVisible; return row; } @@ -934,6 +977,9 @@ class FilesDB { file.exif = row[columnExif]; file.hash = row[columnHash]; file.metadataVersion = row[columnMetadataVersion] ?? 0; + + file.mMdVersion = row[columnMMdVersion] ?? 0 ; + file.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}'; return file; } } diff --git a/lib/models/file.dart b/lib/models/file.dart index 6758f056c..ea48e98f1 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -1,15 +1,16 @@ -import 'package:exif/exif.dart'; +import 'dart:io' as io; + import 'package:flutter/foundation.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:path/path.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; +import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/location.dart'; import 'package:photos/services/feature_flag_service.dart'; import 'package:photos/utils/crypto_util.dart'; -import 'dart:io' as io; class File { int generatedID; @@ -35,6 +36,13 @@ class File { String thumbnailDecryptionHeader; String metadataDecryptionHeader; + String mMdEncodedJson; + int mMdVersion = 0; + MagicMetadata _mmd; + MagicMetadata get magicMetadata => + _mmd ?? MagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}'); + set magicMetadata (val) => _mmd = val; + static const kCurrentMetadataVersion = 1; File(); diff --git a/lib/models/magic_metadata.dart b/lib/models/magic_metadata.dart new file mode 100644 index 000000000..711bc3e9d --- /dev/null +++ b/lib/models/magic_metadata.dart @@ -0,0 +1,35 @@ + +import 'dart:convert'; + +const kVisibilityVisible = 0; +const kVisibilityArchive = 1; + +const kMagicKeyVisibility = 'visibility'; + +class MagicMetadata { + // 0 -> visible + // 1 -> archived + // 2 -> hidden etc? + int visibility; + + MagicMetadata({this.visibility}); + + factory MagicMetadata.fromEncodedJson(String encodedJson) => + MagicMetadata.fromJson(jsonDecode(encodedJson)); + + factory MagicMetadata.fromJson(dynamic json) => + MagicMetadata.fromMap(json); + + Map toJson() { + final map = {}; + map[kMagicKeyVisibility] = visibility; + return map; + } + + factory MagicMetadata.fromMap(Map map) { + if (map == null) return null; + return MagicMetadata( + visibility: map[kMagicKeyVisibility] ?? kVisibilityVisible, + ); + } +} diff --git a/lib/services/file_magic_service.dart b/lib/services/file_magic_service.dart new file mode 100644 index 000000000..fdc871e0b --- /dev/null +++ b/lib/services/file_magic_service.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/core/network.dart'; +import 'package:photos/db/files_db.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/models/file.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/models/magic_metadata.dart'; +import 'package:photos/services/remote_sync_service.dart'; +import 'package:photos/utils/crypto_util.dart'; +import 'package:photos/utils/file_download_util.dart'; + +class FileMagicService { + final _logger = Logger("FileMagicService"); + Dio _dio; + FilesDB _filesDB; + + FileMagicService._privateConstructor() { + _filesDB = FilesDB.instance; + _dio = Network.instance.getDio(); + } + + static final FileMagicService instance = + FileMagicService._privateConstructor(); + + Future changeVisibility(List files, int visibility) async { + Map update = {}; + update[kMagicKeyVisibility] = visibility; + return _updateMagicData(files, update); + } + + Future _updateMagicData( + List files, Map newMetadataUpdate) async { + final params = {}; + params['metadataList'] = []; + final int ownerID = Configuration.instance.getUserID(); + try { + for (final file in files) { + if (file.uploadedFileID == null) { + throw AssertionError( + "operation is only supported on backed up files"); + } else if (file.ownerID != ownerID) { + throw AssertionError("cannot modify memories not owned by you"); + } + // read the existing magic metadata and apply new updates to existing data + // current update is simple replace. This will be enhanced in the future, + // as required. + Map jsonToUpdate = jsonDecode(file.mMdEncodedJson); + newMetadataUpdate.forEach((key, value) { + jsonToUpdate[key] = value; + }); + + // update the local information so that it's reflected on UI + file.mMdEncodedJson = jsonEncode(jsonToUpdate); + file.magicMetadata = MagicMetadata.fromJson(jsonToUpdate); + + final fileKey = decryptFileKey(file); + final encryptedMMd = await CryptoUtil.encryptChaCha( + utf8.encode(jsonEncode(jsonToUpdate)), fileKey); + params['metadataList'].add(UpdateMagicMetadataRequest( + id: file.uploadedFileID, + magicMetadata: MetadataRequest( + version: file.mMdVersion, + count: jsonToUpdate.length, + data: Sodium.bin2base64(encryptedMMd.encryptedData), + header: Sodium.bin2base64(encryptedMMd.header), + ))); + } + + await _dio.put( + Configuration.instance.getHttpEndpoint() + + "/files/magic-metadata", + data: params, + options: Options( + headers: {"X-Auth-Token": Configuration.instance.getToken()}), + ); + // update the state of the selected file. Same file in other collection + // should be eventually synced after remote sync has completed + await _filesDB.insertMultiple(files); + Bus.instance.fire(FilesUpdatedEvent(files)); + RemoteSyncService.instance.sync(silently: true); + } on DioError catch (e) { + if (e.response != null && e.response.statusCode == 409) { + RemoteSyncService.instance.sync(silently: true); + } + rethrow; + } catch (e, s) { + _logger.severe("failed to sync magic metadata", e, s); + rethrow; + } + } +} + +class UpdateMagicMetadataRequest { + final int id; + final MetadataRequest magicMetadata; + + UpdateMagicMetadataRequest({this.id, this.magicMetadata}); + + factory UpdateMagicMetadataRequest.fromJson(dynamic json) { + return UpdateMagicMetadataRequest( + id: json['id'], + magicMetadata: json['magicMetadata'] != null + ? MetadataRequest.fromJson(json['magicMetadata']) + : null); + } + + Map toJson() { + final map = {}; + map['id'] = id; + if (magicMetadata != null) { + map['magicMetadata'] = magicMetadata.toJson(); + } + return map; + } +} + +class MetadataRequest { + int version; + int count; + String data; + String header; + + MetadataRequest({this.version, this.count, this.data, this.header}); + + MetadataRequest.fromJson(dynamic json) { + version = json['version']; + count = json['count']; + data = json['data']; + header = json['header']; + } + + Map toJson() { + var map = {}; + map['version'] = version; + map['count'] = count; + map['data'] = data; + map['header'] = header; + return map; + } +} diff --git a/lib/ui/archive_page.dart b/lib/ui/archive_page.dart new file mode 100644 index 000000000..24a0bf7d3 --- /dev/null +++ b/lib/ui/archive_page.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/db/files_db.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/models/magic_metadata.dart'; +import 'package:photos/models/selected_files.dart'; + +import 'gallery.dart'; +import 'gallery_app_bar_widget.dart'; + +class ArchivePage extends StatelessWidget { + final String tagPrefix; + final GalleryAppBarType appBarType; + final _selectedFiles = SelectedFiles(); + + ArchivePage( + {this.tagPrefix = "archived_page", + this.appBarType = GalleryAppBarType.archive, + Key key}) + : super(key: key); + + @override + Widget build(Object context) { + final gallery = Gallery( + asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) { + return FilesDB.instance.getAllUploadedFiles(creationStartTime, + creationEndTime, Configuration.instance.getUserID(), + visibility: kVisibilityArchive, limit: limit, asc: asc); + }, + reloadEvent: Bus.instance.on().where((event) => + event.updatedFiles + .firstWhere((element) => element.uploadedFileID != null) != + null), + forceReloadEvent: Bus.instance.on().where((event) => + event.updatedFiles + .firstWhere((element) => element.uploadedFileID != null) != + null), + tagPrefix: tagPrefix, + selectedFiles: _selectedFiles, + initialFiles: null, + ); + return Scaffold( + body: Stack(children: [ + Padding( + padding: EdgeInsets.only(top: Platform.isAndroid ? 80 : 100), + child: gallery, + ), + SizedBox( + height: Platform.isAndroid ? 80 : 100, + child: GalleryAppBarWidget( + appBarType, + "archived memories", + _selectedFiles, + ), + ) + ]), + ); + } +} diff --git a/lib/ui/collections_gallery_widget.dart b/lib/ui/collections_gallery_widget.dart index 11e3e4e53..c368fd6e8 100644 --- a/lib/ui/collections_gallery_widget.dart +++ b/lib/ui/collections_gallery_widget.dart @@ -23,6 +23,7 @@ import 'package:photos/ui/thumbnail_widget.dart'; import 'package:photos/utils/local_settings.dart'; import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/toast_util.dart'; +import 'package:photos/ui/archive_page.dart'; class CollectionsGalleryWidget extends StatefulWidget { const CollectionsGalleryWidget({Key key}) : super(key: key); @@ -178,6 +179,44 @@ class _CollectionsGalleryWidgetState extends State ), ) : nothingToSeeHere, + Divider(), + OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + padding: EdgeInsets.fromLTRB(20, 10,20, 10), + side: BorderSide( + width: 2, + color: Colors.white12, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.archive_outlined, + color: Colors.white, + ), + Padding(padding: EdgeInsets.all(6)), + Text( + "archived", + style: TextStyle( + color: Colors.white, + ), + ), + ], + ), + onPressed: () async { + routeToPage( + context, + ArchivePage(), + ); + } + ), + Padding(padding: EdgeInsets.all(12)), ], ), ), diff --git a/lib/ui/gallery_app_bar_widget.dart b/lib/ui/gallery_app_bar_widget.dart index fdcac574f..13ef7df1b 100644 --- a/lib/ui/gallery_app_bar_widget.dart +++ b/lib/ui/gallery_app_bar_widget.dart @@ -1,24 +1,29 @@ import 'dart:async'; import 'dart:io'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:logging/logging.dart'; import 'package:page_transition/page_transition.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/models/collection.dart'; +import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/ui/create_collection_page.dart'; import 'package:photos/ui/share_collection_widget.dart'; import 'package:photos/utils/delete_file_util.dart'; import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/services/file_magic_service.dart'; import 'package:photos/utils/share_util.dart'; import 'package:photos/utils/toast_util.dart'; enum GalleryAppBarType { homepage, + archive, local_folder, // indicator for gallery view of collections shared with the user shared_collection, @@ -208,6 +213,7 @@ class _GalleryAppBarWidgetState extends State { }, )); if (widget.type == GalleryAppBarType.homepage || + widget.type == GalleryAppBarType.archive || widget.type == GalleryAppBarType.local_folder) { actions.add(IconButton( icon: Icon( @@ -235,9 +241,64 @@ class _GalleryAppBarWidgetState extends State { )); } } + + if (widget.type == GalleryAppBarType.homepage || + widget.type == GalleryAppBarType.archive) { + bool showArchive = widget.type == GalleryAppBarType.homepage; + actions.add(PopupMenuButton( + itemBuilder: (context) { + final List items = []; + items.add( + PopupMenuItem( + value: 1, + child: Row( + children: [ + Icon(Platform.isAndroid + ? Icons.archive_outlined + : CupertinoIcons.archivebox), + Padding( + padding: EdgeInsets.all(8), + ), + Text(showArchive ? "archive" : "unarchive"), + ], + ), + ), + ); + return items; + }, + onSelected: (value) async { + if (value == 1) { + await _handleVisibilityChangeRequest(context, showArchive ? kVisibilityArchive : kVisibilityVisible); + } + }, + )); + } return actions; } + Future _handleVisibilityChangeRequest(BuildContext context, + int newVisibility) async { + final dialog = createProgressDialog(context, "please wait..."); + await dialog.show(); + try { + await FileMagicService.instance.changeVisibility( + widget.selectedFiles.files.toList(), newVisibility); + showToast( + newVisibility == kVisibilityArchive + ? "successfully archived" + : "successfully unarchived", + toastLength: Toast.LENGTH_SHORT); + + await dialog.hide(); + } catch (e, s) { + _logger.severe("failed to update file visibility", e, s); + await dialog.hide(); + await showGenericErrorDialog(context); + } finally { + _clearSelectedFiles(); + } + } + void _shareSelected(BuildContext context) { share(context, widget.selectedFiles.files.toList()); } diff --git a/lib/utils/diff_fetcher.dart b/lib/utils/diff_fetcher.dart index 039659f6a..f0bff9cc6 100644 --- a/lib/utils/diff_fetcher.dart +++ b/lib/utils/diff_fetcher.dart @@ -11,6 +11,7 @@ import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/remote_sync_event.dart'; import 'package:photos/models/file.dart'; +import 'package:photos/models/magic_metadata.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_download_util.dart'; @@ -71,14 +72,24 @@ class DiffFetcher { item["thumbnail"]["decryptionHeader"]; file.metadataDecryptionHeader = item["metadata"]["decryptionHeader"]; + + final fileDecryptionKey = decryptFileKey(file); final encodedMetadata = await CryptoUtil.decryptChaCha( Sodium.base642bin(item["metadata"]["encryptedData"]), - decryptFileKey(file), + fileDecryptionKey, Sodium.base642bin(file.metadataDecryptionHeader), ); Map metadata = jsonDecode(utf8.decode(encodedMetadata)); file.applyMetadata(metadata); + if (item['magicMetadata'] != null) { + final utfEncodedMmd = await CryptoUtil.decryptChaCha( + Sodium.base642bin(item['magicMetadata']['data']), fileDecryptionKey, + Sodium.base642bin(item['magicMetadata']['header'])); + file.mMdEncodedJson = utf8.decode(utfEncodedMmd); + file.mMdVersion = item['magicMetadata']['version']; + file.magicMetadata = MagicMetadata.fromEncodedJson(file.mMdEncodedJson); + } files.add(file); }