Merge pull request #46 from ente-io/archive_unarchive

Add support for archiving memories
This commit is contained in:
Neeraj Gupta 2021-09-22 08:10:10 +05:30 committed by GitHub
commit 82a9e88cd7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 421 additions and 13 deletions

View file

@ -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<String> 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<void> clearTable() async {
final db = await instance.database;
await db.delete(table);
@ -332,20 +357,22 @@ class FilesDB {
}
Future<FileLoadResult> 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<File> deduplicatedFiles = _deduplicatedFiles(files);
return FileLoadResult(deduplicatedFiles, files.length == limit);
}
Future<FileLoadResult> 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 = <int>{};
final files = _convertToFiles(results);
List<File> deduplicatedFiles = _deduplicatedFiles(files);
return FileLoadResult(deduplicatedFiles, files.length == limit);
}
List<File> _deduplicatedFiles(List<File> files) {
final uploadedFileIDs = <int>{};
final List<File> 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<FileLoadResult> 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;
}
}

View file

@ -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();

View file

@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
map[kMagicKeyVisibility] = visibility;
return map;
}
factory MagicMetadata.fromMap(Map<String, dynamic> map) {
if (map == null) return null;
return MagicMetadata(
visibility: map[kMagicKeyVisibility] ?? kVisibilityVisible,
);
}
}

View file

@ -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<void> changeVisibility(List<File> files, int visibility) async {
Map<String, dynamic> update = {};
update[kMagicKeyVisibility] = visibility;
return _updateMagicData(files, update);
}
Future<void> _updateMagicData(
List<File> files, Map<String, dynamic> newMetadataUpdate) async {
final params = <String, dynamic>{};
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<String, dynamic> 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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<String, dynamic> toJson() {
var map = <String, dynamic>{};
map['version'] = version;
map['count'] = count;
map['data'] = data;
map['header'] = header;
return map;
}
}

63
lib/ui/archive_page.dart Normal file
View file

@ -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<FilesUpdatedEvent>().where((event) =>
event.updatedFiles
.firstWhere((element) => element.uploadedFileID != null) !=
null),
forceReloadEvent: Bus.instance.on<FilesUpdatedEvent>().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,
),
)
]),
);
}
}

View file

@ -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<CollectionsGalleryWidget>
),
)
: 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)),
],
),
),

View file

@ -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<GalleryAppBarWidget> {
},
));
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<GalleryAppBarWidget> {
));
}
}
if (widget.type == GalleryAppBarType.homepage ||
widget.type == GalleryAppBarType.archive) {
bool showArchive = widget.type == GalleryAppBarType.homepage;
actions.add(PopupMenuButton(
itemBuilder: (context) {
final List<PopupMenuItem> 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<void> _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());
}

View file

@ -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<String, dynamic> 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);
}