diff --git a/lib/db/collections_db.dart b/lib/db/collections_db.dart index b66c01704..dee08e9be 100644 --- a/lib/db/collections_db.dart +++ b/lib/db/collections_db.dart @@ -30,6 +30,9 @@ class CollectionsDB { // MMD -> Magic Metadata static const columnMMdEncodedJson = 'mmd_encoded_json'; static const columnMMdVersion = 'mmd_ver'; + + static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json'; + static const columnPubMMdVersion = 'pub_mmd_ver'; static const columnUpdationTime = 'updation_time'; static const columnIsDeleted = 'is_deleted'; @@ -41,6 +44,7 @@ class CollectionsDB { ...addIsDeleted(), ...addPublicURLs(), ...addPrivateMetadata(), + ...addPublicMetadata(), ]; final dbConfig = MigrationConfig( @@ -156,6 +160,18 @@ class CollectionsDB { ]; } + static List addPublicMetadata() { + return [ + ''' + ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT ' + {}'; + ''', + ''' + ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0; + ''' + ]; + } + Future insert(List collections) async { final db = await instance.database; var batch = db.batch(); @@ -238,6 +254,8 @@ class CollectionsDB { } row[columnMMdVersion] = collection.mMdVersion; row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}'; + row[columnPubMMdVersion] = collection.mMbPubVersion; + row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}'; return row; } @@ -271,6 +289,8 @@ class CollectionsDB { ); result.mMdVersion = row[columnMMdVersion] ?? 0; result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}'; + result.mMbPubVersion = row[columnPubMMdVersion] ?? 0; + result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}'; return result; } } diff --git a/lib/events/collection_meta_event.dart b/lib/events/collection_meta_event.dart new file mode 100644 index 000000000..d0fcb7d71 --- /dev/null +++ b/lib/events/collection_meta_event.dart @@ -0,0 +1,16 @@ +import 'package:photos/events/event.dart'; + +class CollectionMetaEvent extends Event { + final int id; + final CollectionMetaEventType type; + + CollectionMetaEvent(this.id, this.type); +} + +enum CollectionMetaEventType { + created, + deleted, + archived, + sortChanged, + thumbnailChanged, +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 2b6d83ac7..6aa4a4d91 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -1096,6 +1096,9 @@ class MessageLookup extends MessageLookupByLibrary { "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": MessageLookupByLibrary.simpleMessage( "Sorry, we could not generate secure keys on this device.\n\nplease sign up from a different device."), + "sortAlbumsBy": MessageLookupByLibrary.simpleMessage("Sort by"), + "sortNewestFirst": MessageLookupByLibrary.simpleMessage("Newest first"), + "sortOldestFirst": MessageLookupByLibrary.simpleMessage("Oldest first"), "sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"), "startBackup": MessageLookupByLibrary.simpleMessage("Start backup"), "storage": MessageLookupByLibrary.simpleMessage("Storage"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 43e1461fc..3c11fd73f 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -5233,6 +5233,36 @@ class S { ); } + /// `Sort by` + String get sortAlbumsBy { + return Intl.message( + 'Sort by', + name: 'sortAlbumsBy', + desc: '', + args: [], + ); + } + + /// `Newest first` + String get sortNewestFirst { + return Intl.message( + 'Newest first', + name: 'sortNewestFirst', + desc: '', + args: [], + ); + } + + /// `Oldest first` + String get sortOldestFirst { + return Intl.message( + 'Oldest first', + name: 'sortOldestFirst', + desc: '', + args: [], + ); + } + /// `Rename` String get rename { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2c5628244..9386d1557 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -748,6 +748,9 @@ }, "deleteAll": "Delete All", "renameAlbum": "Rename album", + "sortAlbumsBy": "Sort by", + "sortNewestFirst": "Newest first", + "sortOldestFirst": "Oldest first", "rename": "Rename", "leaveSharedAlbum": "Leave shared album?", "leaveAlbum": "Leave album", diff --git a/lib/models/collection.dart b/lib/models/collection.dart index d9f09095a..c7241b3ba 100644 --- a/lib/models/collection.dart +++ b/lib/models/collection.dart @@ -22,13 +22,22 @@ class Collection { final int updationTime; final bool isDeleted; String? mMdEncodedJson; + String? mMdPubEncodedJson; int mMdVersion = 0; + int mMbPubVersion = 0; CollectionMagicMetadata? _mmd; + CollectionPubMagicMetadata? _pubMmd; CollectionMagicMetadata get magicMetadata => _mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}'); - set magicMetadata(val) => _mmd = val; + CollectionPubMagicMetadata get pubMagicMetadata => + _pubMmd ?? + CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}'); + + set magicMetadata(CollectionMagicMetadata? val) => _mmd = val; + + set pubMagicMetadata(CollectionPubMagicMetadata? val) => _pubMmd = val; Collection( this.id, @@ -162,6 +171,8 @@ class Collection { ); result.mMdVersion = mMdVersion ?? this.mMdVersion; result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson; + result.mMbPubVersion = mMbPubVersion; + result.mMdPubEncodedJson = mMdPubEncodedJson; return result; } diff --git a/lib/models/metadata/collection_magic.dart b/lib/models/metadata/collection_magic.dart index 351713e7c..7531deddd 100644 --- a/lib/models/metadata/collection_magic.dart +++ b/lib/models/metadata/collection_magic.dart @@ -44,3 +44,35 @@ class CollectionMagicMetadata { ); } } + +class CollectionPubMagicMetadata { + // sort order while showing collection + bool? asc; + + // cover photo id for the collection + int? coverID; + + CollectionPubMagicMetadata({this.asc, this.coverID}); + + Map toJson() { + final Map result = {"asc": asc ?? false}; + if (coverID != null) { + result["coverID"] = coverID!; + } + return result; + } + + factory CollectionPubMagicMetadata.fromEncodedJson(String encodedJson) => + CollectionPubMagicMetadata.fromJson(jsonDecode(encodedJson)); + + factory CollectionPubMagicMetadata.fromJson(dynamic json) => + CollectionPubMagicMetadata.fromMap(json); + + static fromMap(Map? map) { + if (map == null) return null; + return CollectionPubMagicMetadata( + asc: map["asc"] as bool?, + coverID: map["coverID"], + ); + } +} diff --git a/lib/services/collections_service.dart b/lib/services/collections_service.dart index 449be6e3c..8b0f48b26 100644 --- a/lib/services/collections_service.dart +++ b/lib/services/collections_service.dart @@ -610,6 +610,65 @@ class CollectionsService { } } + Future updatePublicMagicMetadata( + Collection collection, + Map newMetadataUpdate, + ) async { + final int ownerID = Configuration.instance.getUserID()!; + try { + if (collection.owner?.id != ownerID) { + throw AssertionError("cannot modify albums 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. + final Map jsonToUpdate = + jsonDecode(collection.mMdPubEncodedJson ?? '{}'); + newMetadataUpdate.forEach((key, value) { + jsonToUpdate[key] = value; + }); + + final key = getCollectionKey(collection.id); + final encryptedMMd = await CryptoUtil.encryptChaCha( + utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List, + key, + ); + // for required field, the json validator on golang doesn't treat 0 as valid + // value. Instead of changing version to ptr, decided to start version with 1. + final int currentVersion = max(collection.mMbPubVersion, 1); + final params = UpdateMagicMetadataRequest( + id: collection.id, + magicMetadata: MetadataRequest( + version: currentVersion, + count: jsonToUpdate.length, + data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!), + header: CryptoUtil.bin2base64(encryptedMMd.header!), + ), + ); + await _enteDio.put( + "/collections/public-magic-metadata", + data: params, + ); + // update the local information so that it's reflected on UI + collection.mMdPubEncodedJson = jsonEncode(jsonToUpdate); + collection.pubMagicMetadata = + CollectionPubMagicMetadata.fromJson(jsonToUpdate); + collection.mMbPubVersion = currentVersion + 1; + _cacheCollectionAttributes(collection); + // trigger sync to fetch the latest collection state from server + sync().ignore(); + } on DioError catch (e) { + if (e.response != null && e.response?.statusCode == 409) { + _logger.severe('collection magic data out of sync'); + sync().ignore(); + } + rethrow; + } catch (e, s) { + _logger.severe("failed to sync magic metadata", e, s); + rethrow; + } + } + Future createShareUrl( Collection collection, { bool enableCollect = false, @@ -701,9 +760,9 @@ class CollectionsService { final c = response.data["collections"]; for (final collectionData in c) { final collection = Collection.fromMap(collectionData); + final collectionKey = + _getAndCacheDecryptedKey(collection, source: "fetchCollection"); if (collectionData['magicMetadata'] != null) { - final collectionKey = - _getAndCacheDecryptedKey(collection, source: "fetchCollection"); final utfEncodedMmd = await CryptoUtil.decryptChaCha( CryptoUtil.base642bin(collectionData['magicMetadata']['data']), collectionKey, @@ -715,6 +774,21 @@ class CollectionsService { collection.mMdEncodedJson, ); } + + if (collectionData['pubMagicMetadata'] != null) { + final utfEncodedMmd = await CryptoUtil.decryptChaCha( + CryptoUtil.base642bin(collectionData['pubMagicMetadata']['data']), + collectionKey, + CryptoUtil.base642bin(collectionData['pubMagicMetadata']['header']), + ); + collection.mMdPubEncodedJson = utf8.decode(utfEncodedMmd); + collection.mMbPubVersion = + collectionData['pubMagicMetadata']['version']; + collection.pubMagicMetadata = + CollectionPubMagicMetadata.fromEncodedJson( + collection.mMdEncodedJson, + ); + } collections.add(collection); } return collections; diff --git a/lib/ui/viewer/gallery/collection_page.dart b/lib/ui/viewer/gallery/collection_page.dart index 5cc0dd314..9de7ef18f 100644 --- a/lib/ui/viewer/gallery/collection_page.dart +++ b/lib/ui/viewer/gallery/collection_page.dart @@ -2,6 +2,7 @@ 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/collection_meta_event.dart"; import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/models/collection.dart'; @@ -65,6 +66,13 @@ class CollectionPage extends StatelessWidget { reloadEvent: Bus.instance .on() .where((event) => event.collectionID == c.collection.id), + forceReloadEvents: [ + Bus.instance.on().where( + (event) => + event.id == c.collection.id && + event.type == CollectionMetaEventType.sortChanged, + ) + ], removalEventTypes: const { EventType.deletedFromRemote, EventType.deletedFromEverywhere, @@ -74,7 +82,7 @@ class CollectionPage extends StatelessWidget { selectedFiles: _selectedFiles, initialFiles: initialFiles, albumName: c.collection.name, - sortOrderAsc: false, + sortAsyncFn: () => c.collection.pubMagicMetadata.asc ?? false, ); return Scaffold( appBar: PreferredSize( diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index 403146a32..22c9c5a6d 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -25,6 +25,8 @@ typedef GalleryLoader = Future Function( bool? asc, }); +typedef SortAscFn = bool Function(); + class Gallery extends StatefulWidget { final GalleryLoader asyncLoader; final List? initialFiles; @@ -42,7 +44,9 @@ class Gallery extends StatefulWidget { final Widget loadingWidget; final bool disableScroll; final bool limitSelectionToOne; - final bool sortOrderAsc; + + // add a Function variable to get sort value in bool + final SortAscFn? sortAsyncFn; const Gallery({ required this.asyncLoader, @@ -61,7 +65,7 @@ class Gallery extends StatefulWidget { this.loadingWidget = const EnteLoadingWidget(), this.disableScroll = false, this.limitSelectionToOne = false, - this.sortOrderAsc = false, + this.sortAsyncFn, Key? key, }) : super(key: key); @@ -84,6 +88,7 @@ class _GalleryState extends State { StreamSubscription? _tabDoubleTapEvent; final _forceReloadEventSubscriptions = >[]; late String _logTag; + bool _sortOrderAsc = false; @override void initState() { @@ -91,6 +96,7 @@ class _GalleryState extends State { "Gallery_${widget.tagPrefix}${kDebugMode ? "_" + widget.albumName! : ""}"; _logger = Logger(_logTag); _logger.finest("init Gallery"); + _sortOrderAsc = widget.sortAsyncFn != null ? widget.sortAsyncFn!() : false; _itemScroller = ItemScrollController(); if (widget.reloadEvent != null) { _reloadEventSubscription = widget.reloadEvent!.listen((event) async { @@ -122,13 +128,15 @@ class _GalleryState extends State { _forceReloadEventSubscriptions.add( event.listen((event) async { _logger.finest("Force refresh all files on ${event.reason}"); + _sortOrderAsc = + widget.sortAsyncFn != null ? widget.sortAsyncFn!() : false; final result = await _loadFiles(); _setFilesAndReload(result.files); }), ); } } - if (widget.initialFiles != null && !widget.sortOrderAsc) { + if (widget.initialFiles != null && !_sortOrderAsc) { _onFilesLoaded(widget.initialFiles!); } _loadFiles(limit: kInitialLoadLimit).then((result) async { @@ -156,7 +164,7 @@ class _GalleryState extends State { galleryLoadStartTime, galleryLoadEndTime, limit: limit, - asc: widget.sortOrderAsc, + asc: _sortOrderAsc, ); final endTime = DateTime.now().microsecondsSinceEpoch; final duration = Duration(microseconds: endTime - startTime); @@ -216,7 +224,7 @@ class _GalleryState extends State { disableScroll: widget.disableScroll, emptyState: widget.emptyState, asyncLoader: widget.asyncLoader, - sortOrderAsc: widget.sortOrderAsc, + sortOrderAsc: _sortOrderAsc, removalEventTypes: widget.removalEventTypes, tagPrefix: widget.tagPrefix, scrollBottomSafeArea: widget.scrollBottomSafeArea, @@ -248,7 +256,7 @@ class _GalleryState extends State { if (dailyFiles.isNotEmpty) { collatedFiles.add(dailyFiles); } - if (widget.sortOrderAsc) { + if (_sortOrderAsc) { collatedFiles .sort((a, b) => a[0].creationTime!.compareTo(b[0].creationTime!)); } else { diff --git a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index a550d3ee6..c8f6ebf06 100644 --- a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -283,6 +283,22 @@ class _GalleryAppBarWidgetState extends State { // Do not show archive option for favorite collection. If collection is // already archived, allow user to unarchive that collection. if (isArchived || widget.collection!.type != CollectionType.favorites) { + items.add( + PopupMenuItem( + value: 6, + child: Row( + children: [ + const Icon(Icons.sort_outlined), + const Padding( + padding: EdgeInsets.all(8), + ), + Text( + S.of(context).sortAlbumsBy, + ), + ], + ), + ), + ); items.add( PopupMenuItem( value: 2, @@ -375,6 +391,8 @@ class _GalleryAppBarWidgetState extends State { await _leaveAlbum(context); } else if (value == 5) { await _deleteBackedUpFiles(context); + } else if (value == 6) { + await _showSortOption(context); } else { showToast(context, S.of(context).somethingWentWrong); } @@ -386,6 +404,31 @@ class _GalleryAppBarWidgetState extends State { return actions; } + Future _showSortOption(BuildContext bContext) async { + final bool? sortByAsc = await showMenu( + context: bContext, + position: RelativeRect.fromLTRB( + MediaQuery.of(context).size.width, + kToolbarHeight + 12, + 12, + 0, + ), + items: [ + PopupMenuItem( + value: false, + child: Text(S.of(context).sortNewestFirst), + ), + PopupMenuItem( + value: true, + child: Text(S.of(context).sortOldestFirst), + ), + ], + ); + if (sortByAsc != null) { + changeSortOrder(bContext, widget.collection!, sortByAsc); + } + } + Future _trashCollection() async { final collectionWithThumbnail = await CollectionsService.instance.getCollectionsWithThumbnails(); diff --git a/lib/utils/magic_util.dart b/lib/utils/magic_util.dart index aadea7171..b879b793a 100644 --- a/lib/utils/magic_util.dart +++ b/lib/utils/magic_util.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:photos/core/event_bus.dart'; +import "package:photos/events/collection_meta_event.dart"; import 'package:photos/events/force_reload_home_gallery_event.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/models/collection.dart'; @@ -77,6 +78,25 @@ Future changeCollectionVisibility( } } +Future changeSortOrder( + BuildContext context, + Collection collection, + bool sortedInAscOrder, +) async { + try { + final Map update = {"asc": sortedInAscOrder}; + await CollectionsService.instance + .updatePublicMagicMetadata(collection, update); + Bus.instance.fire( + CollectionMetaEvent(collection.id, CollectionMetaEventType.sortChanged), + ); + } catch (e, s) { + _logger.severe("failed to update collection visibility", e, s); + showShortToast(context, S.of(context).somethingWentWrong); + rethrow; + } +} + Future editTime( BuildContext context, List files,