瀏覽代碼

Merge branch 'master' into deduplicate

vishnukvmd 3 年之前
父節點
當前提交
2ee21cd928

+ 1 - 0
android/app/src/debug/res/values/strings.xml

@@ -1,3 +1,4 @@
 <resources>
     <string name="app_name">ente debug</string>
+    <string name="backup">backup debug</string>
 </resources>

+ 14 - 2
android/app/src/main/AndroidManifest.xml

@@ -20,18 +20,30 @@
             </intent-filter>
 
             <!--Filter to support sharing images into our app-->
-            <intent-filter android:label="backup">
+            <intent-filter android:label="@string/backup">
                 <action android:name="android.intent.action.SEND" />
                 <category android:name="android.intent.category.DEFAULT" />
                 <data android:mimeType="image/*" />
             </intent-filter>
 
-            <intent-filter android:label="backup">
+            <intent-filter android:label="@string/backup">
                 <action android:name="android.intent.action.SEND_MULTIPLE" />
                 <category android:name="android.intent.category.DEFAULT" />
                 <data android:mimeType="image/*" />
             </intent-filter>
 
+            <intent-filter android:label="@string/backup">
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="video/*" />
+            </intent-filter>
+
+            <intent-filter android:label="@string/backup">
+                <action android:name="android.intent.action.SEND_MULTIPLE" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="video/*" />
+            </intent-filter>
+
         </activity>
         <!-- Don't delete the meta-data below.
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

+ 1 - 0
android/app/src/main/res/values/strings.xml

@@ -1,3 +1,4 @@
 <resources>
     <string name="app_name">ente</string>
+    <string name="backup">backup</string>
 </resources>

+ 2 - 1
lib/core/network.dart

@@ -4,13 +4,14 @@ import 'package:dio/dio.dart';
 import 'package:flutter_user_agent/flutter_user_agent.dart';
 import 'package:package_info_plus/package_info_plus.dart';
 
+int kConnectTimeout = 5000;
 class Network {
   Dio _dio;
 
   Future<void> init() async {
     await FlutterUserAgent.init();
     final packageInfo = await PackageInfo.fromPlatform();
-    _dio = Dio(BaseOptions(headers: {
+    _dio = Dio(BaseOptions(connectTimeout: kConnectTimeout, headers: {
       HttpHeaders.userAgentHeader: FlutterUserAgent.userAgent,
       'X-Client-Version': packageInfo.version,
       'X-Client-Package': packageInfo.packageName,

+ 58 - 12
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<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,31 +357,34 @@ class FilesDB {
   }
 
   Future<FileLoadResult> getAllUploadedFiles(int startTime, int endTime,
-      {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 ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)',
-      whereArgs: [startTime, endTime],
+          '$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,
+  Future<FileLoadResult> getAllLocalAndUploadedFiles(int startTime, int endTime, int ownerID,
       {int limit, bool asc}) async {
     final db = await instance.database;
     final order = (asc ?? false ? 'ASC' : 'DESC');
     final results = await db.query(
       table,
       where:
-          '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
-      whereArgs: [startTime, endTime],
+          '$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,
@@ -366,7 +394,7 @@ class FilesDB {
   }
 
   Future<FileLoadResult> getImportantFiles(
-      int startTime, int endTime, List<String> paths,
+      int startTime, int endTime, int ownerID, List<String> paths,
       {int limit, bool asc}) async {
     final db = await instance.database;
     String inParam = "";
@@ -378,14 +406,20 @@ class FilesDB {
     final results = await db.query(
       table,
       where:
-          '$columnCreationTime >= ? AND $columnCreationTime <= ? AND (($columnLocalID IS NOT NULL AND $columnDeviceFolder IN ($inParam)) OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
-      whereArgs: [startTime, endTime],
+          '$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(
@@ -896,6 +930,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;
   }
 
@@ -922,6 +960,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;
   }
 
@@ -953,6 +996,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;
   }
 }

+ 10 - 1
lib/models/file.dart

@@ -1,14 +1,15 @@
+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;
@@ -34,6 +35,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();
@@ -51,6 +59,7 @@ class File {
         final parsedDateTime = DateTime.parse(
             basenameWithoutExtension(file.title)
                 .replaceAll("IMG_", "")
+                .replaceAll("VID_", "")
                 .replaceAll("DCIM_", "")
                 .replaceAll("_", " "));
         file.creationTime = parsedDateTime.microsecondsSinceEpoch;

+ 35 - 0
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<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,
+    );
+  }
+}

+ 145 - 0
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<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;
+  }
+}

+ 65 - 0
lib/ui/archive_page.dart

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

+ 39 - 0
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<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)),
           ],
         ),
       ),

+ 1 - 0
lib/ui/create_collection_page.dart

@@ -254,6 +254,7 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
       return false;
     } catch (e, s) {
       _logger.severe("Could not move to album", e, s);
+      await dialog.hide();
       showGenericErrorDialog(context);
       return false;
     }

+ 1 - 0
lib/ui/detail_page.dart

@@ -106,6 +106,7 @@ class _DetailPageState extends State<DetailPage> {
       appBar: FadingAppBar(
         _files[_selectedIndex],
         _onFileDeleted,
+        Configuration.instance.getUserID(),
         100,
         widget.config.mode == DetailPageMode.full,
         key: _appBarKey,

+ 25 - 16
lib/ui/fading_app_bar.dart

@@ -25,10 +25,12 @@ class FadingAppBar extends StatefulWidget implements PreferredSizeWidget {
   final Function(File) onFileDeleted;
   final double height;
   final bool shouldShowActions;
+  final int userID;
 
   FadingAppBar(
     this.file,
     this.onFileDeleted,
+    this.userID,
     this.height,
     this.shouldShowActions, {
     Key key,
@@ -85,7 +87,10 @@ class FadingAppBarState extends State<FadingAppBar> {
 
   AppBar _buildAppBar() {
     final List<Widget> actions = [];
-    actions.add(_getFavoriteButton());
+    // only show fav option for files owned by the user
+    if (widget.file.ownerID == null || widget.file.ownerID == widget.userID) {
+      actions.add(_getFavoriteButton());
+    }
     actions.add(PopupMenuButton(
       itemBuilder: (context) {
         final List<PopupMenuItem> items = [];
@@ -107,22 +112,26 @@ class FadingAppBarState extends State<FadingAppBar> {
             ),
           );
         }
-        items.add(
-          PopupMenuItem(
-            value: 2,
-            child: Row(
-              children: [
-                Icon(Platform.isAndroid
-                    ? Icons.delete_outline
-                    : CupertinoIcons.delete),
-                Padding(
-                  padding: EdgeInsets.all(8),
-                ),
-                Text("delete"),
-              ],
+        // only show delete option for files owned by the user
+        if (widget.file.ownerID == null ||
+            widget.file.ownerID == widget.userID) {
+          items.add(
+            PopupMenuItem(
+              value: 2,
+              child: Row(
+                children: [
+                  Icon(Platform.isAndroid
+                      ? Icons.delete_outline
+                      : CupertinoIcons.delete),
+                  Padding(
+                    padding: EdgeInsets.all(8),
+                  ),
+                  Text("delete"),
+                ],
+              ),
             ),
-          ),
-        );
+          );
+        }
         return items;
       },
       onSelected: (value) {

+ 23 - 12
lib/ui/gallery.dart

@@ -100,18 +100,29 @@ class _GalleryState extends State<Gallery> {
 
   Future<FileLoadResult> _loadFiles({int limit}) async {
     _logger.info("Loading files");
-    final startTime = DateTime.now().microsecondsSinceEpoch;
-    final result = await widget.asyncLoader(
-        kGalleryLoadStartTime, DateTime.now().microsecondsSinceEpoch,
-        limit: limit);
-    final endTime = DateTime.now().microsecondsSinceEpoch;
-    final duration = Duration(microseconds: endTime - startTime);
-    _logger.info("Time taken to load " +
-        result.files.length.toString() +
-        " files :" +
-        duration.inMilliseconds.toString() +
-        "ms");
-    return result;
+    try {
+      final startTime = DateTime
+          .now()
+          .microsecondsSinceEpoch;
+      final result = await widget.asyncLoader(
+          kGalleryLoadStartTime, DateTime
+          .now()
+          .microsecondsSinceEpoch,
+          limit: limit);
+      final endTime = DateTime
+          .now()
+          .microsecondsSinceEpoch;
+      final duration = Duration(microseconds: endTime - startTime);
+      _logger.info("Time taken to load " +
+          result.files.length.toString() +
+          " files :" +
+          duration.inMilliseconds.toString() +
+          "ms");
+      return result;
+    } catch(e, s) {
+      _logger.severe("failed to load files", e, s);
+      rethrow;
+    }
   }
 
   // Collates files and returns `true` if it resulted in a gallery reload

+ 61 - 0
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<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());
   }

+ 5 - 4
lib/ui/home_widget.dart

@@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:logging/logging.dart';
 import 'package:move_to_background/move_to_background.dart';
 import 'package:photo_manager/photo_manager.dart';
@@ -45,7 +46,6 @@ import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:receive_sharing_intent/receive_sharing_intent.dart';
 import 'package:uni_links/uni_links.dart';
-import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 class HomeWidget extends StatefulWidget {
   const HomeWidget({Key key}) : super(key: key);
@@ -336,18 +336,19 @@ class _HomeWidgetState extends State<HomeWidget> {
     final gallery = Gallery(
       asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) {
         final importantPaths = Configuration.instance.getPathsToBackUp();
+        final ownerID = Configuration.instance.getUserID();
         if (importantPaths.isNotEmpty) {
           return FilesDB.instance.getImportantFiles(
-              creationStartTime, creationEndTime, importantPaths.toList(),
+              creationStartTime, creationEndTime, ownerID, importantPaths.toList(),
               limit: limit, asc: asc);
         } else {
           if (LocalSyncService.instance.hasGrantedLimitedPermissions()) {
             return FilesDB.instance.getAllLocalAndUploadedFiles(
-                creationStartTime, creationEndTime,
+                creationStartTime, creationEndTime, ownerID,
                 limit: limit, asc: asc);
           } else {
             return FilesDB.instance.getAllUploadedFiles(
-                creationStartTime, creationEndTime,
+                creationStartTime, creationEndTime, ownerID,
                 limit: limit, asc: asc);
           }
         }

+ 16 - 6
lib/ui/video_widget.dart

@@ -43,6 +43,14 @@ class _VideoWidgetState extends State<VideoWidget> {
     super.initState();
     if (widget.file.isRemoteFile()) {
       _loadNetworkVideo();
+    } else if (widget.file.isSharedMediaToAppSandbox()) {
+      final localFile = io.File(getSharedMediaFilePath(widget.file));
+      if (localFile.existsSync()) {
+        _logger.fine("loading from app cache");
+        _setVideoPlayerController(file: localFile);
+      } else if (widget.file.uploadedFileID != null) {
+        _loadNetworkVideo();
+      }
     } else {
       widget.file.getAsset().then((asset) async {
         if (asset == null || !(await asset.exists)) {
@@ -62,12 +70,14 @@ class _VideoWidgetState extends State<VideoWidget> {
     getFileFromServer(
       widget.file,
       progressCallback: (count, total) {
-        setState(() {
-          _progress = count / total;
-          if (_progress == 1) {
-            showToast("decrypting video...", toastLength: Toast.LENGTH_SHORT);
-          }
-        });
+        if (mounted) {
+          setState(() {
+            _progress = count / total;
+            if (_progress == 1) {
+              showToast("decrypting video...", toastLength: Toast.LENGTH_SHORT);
+            }
+          });
+        }
       },
     ).then((file) {
       if (file != null) {

+ 12 - 1
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<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);
             }
 

+ 20 - 2
lib/utils/file_uploader_util.dart

@@ -6,6 +6,7 @@ import 'package:archive/archive_io.dart';
 import 'package:logging/logging.dart';
 import 'package:motionphoto/motionphoto.dart';
 import 'package:path/path.dart';
+import 'package:path_provider/path_provider.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/constants.dart';
@@ -13,6 +14,7 @@ import 'package:photos/core/errors.dart';
 import 'package:photos/models/file.dart' as ente;
 import 'package:photos/models/file_type.dart';
 import 'package:photos/models/location.dart';
+import 'package:video_thumbnail/video_thumbnail.dart';
 
 import 'file_util.dart';
 
@@ -140,8 +142,14 @@ Future<MediaUploadData> _getMediaUploadDataFromAppCache(ente.File file) async {
     _logger.warning("File doesn't exist in app sandbox");
     throw InvalidFileError("File doesn't exist in app sandbox");
   }
-  thumbnailData = await getThumbnailFromInAppCacheFile(file);
-  return MediaUploadData(sourceFile, thumbnailData, isDeleted);
+  try {
+    thumbnailData = await getThumbnailFromInAppCacheFile(file);
+    return MediaUploadData(sourceFile, thumbnailData, isDeleted);
+  } catch (e, s) {
+    _logger.severe("failed to generate thumbnail", e, s);
+    throw InvalidFileError(
+        "thumbnail generation failed for fileType: ${file.fileType.toString()}");
+  }
 }
 
 Future<Uint8List> getThumbnailFromInAppCacheFile(ente.File file) async {
@@ -149,6 +157,16 @@ Future<Uint8List> getThumbnailFromInAppCacheFile(ente.File file) async {
   if (!localFile.existsSync()) {
     return null;
   }
+  if (file.fileType == FileType.video) {
+    final thumbnailFilePath = await VideoThumbnail.thumbnailFile(
+      video: localFile.path,
+      imageFormat: ImageFormat.JPEG,
+      thumbnailPath: (await getTemporaryDirectory()).path,
+      maxWidth: kThumbnailLargeSize,
+      quality: 80,
+    );
+    localFile = io.File(thumbnailFilePath);
+  }
   var thumbnailData = await localFile.readAsBytes();
   int compressionAttempts = 0;
   while (thumbnailData.length > kThumbnailDataLimit &&

+ 27 - 15
lib/utils/share_util.dart

@@ -40,29 +40,40 @@ Future<List<File>> convertIncomingSharedMediaToFile(
     List<SharedMediaFile> sharedMedia, int collectionID) async {
   List<File> localFiles = [];
   for (var media in sharedMedia) {
+    if (!(media.type == SharedMediaType.IMAGE ||
+        media.type == SharedMediaType.VIDEO)) {
+      _logger.warning(
+          "ignore unsupported file type ${media.type.toString()} path: ${media.path}");
+      continue;
+    }
     var enteFile = File();
     // fileName: img_x.jpg
     enteFile.title = basename(media.path);
-
     var ioFile = dartio.File(media.path);
-    ioFile = ioFile.renameSync(Configuration.instance.getSharedMediaCacheDirectory() +
-        "/" +
-        enteFile.title);
+    ioFile = ioFile.renameSync(
+        Configuration.instance.getSharedMediaCacheDirectory() +
+            "/" +
+            enteFile.title);
     enteFile.localID = kSharedMediaIdentifier + enteFile.title;
     enteFile.collectionID = collectionID;
-    enteFile.fileType = FileType.image;
+    enteFile.fileType =
+        media.type == SharedMediaType.IMAGE ? FileType.image : FileType.video;
 
-    var exifMap = await readExifFromFile(ioFile);
-    if (exifMap != null &&
-        exifMap["Image DateTime"] != null &&
-        '0000:00:00 00:00:00' != exifMap["Image DateTime"].toString()) {
-      try {
-        final exifTime =
-            _exifDateFormat.parse(exifMap["Image DateTime"].toString());
-        enteFile.creationTime = exifTime.microsecondsSinceEpoch;
-      } catch (e) {
-        //ignore
+    if (enteFile.fileType == FileType.image) {
+      final exifMap = await readExifFromFile(ioFile);
+      if (exifMap != null &&
+          exifMap["Image DateTime"] != null &&
+          '0000:00:00 00:00:00' != exifMap["Image DateTime"].toString()) {
+        try {
+          final exifTime =
+              _exifDateFormat.parse(exifMap["Image DateTime"].toString());
+          enteFile.creationTime = exifTime.microsecondsSinceEpoch;
+        } catch (e) {
+          //ignore
+        }
       }
+    } else if (enteFile.fileType == FileType.video) {
+      enteFile.duration = media.duration ?? 0;
     }
     if (enteFile.creationTime == null || enteFile.creationTime == 0) {
       final parsedDateTime =
@@ -90,6 +101,7 @@ DateTime parseDateFromFileName(String fileName) {
   } else {
     return DateTime.tryParse((fileName)
         .replaceAll("IMG_", "")
+        .replaceAll("VID_", "")
         .replaceAll("DCIM_", "")
         .replaceAll("_", " "));
   }

+ 20 - 13
pubspec.lock

@@ -7,7 +7,7 @@ packages:
       name: animate_do
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.7.5"
+    version: "2.0.0"
   archive:
     dependency: "direct main"
     description:
@@ -91,14 +91,14 @@ packages:
       name: computer
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.1"
+    version: "2.0.0"
   confetti:
     dependency: "direct main"
     description:
       name: confetti
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.5.5"
+    version: "0.6.0"
   connectivity:
     dependency: "direct main"
     description:
@@ -428,7 +428,7 @@ packages:
       name: flutter_windowmanager
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.0.2"
+    version: "0.2.0"
   fluttercontactpicker:
     dependency: "direct main"
     description:
@@ -592,6 +592,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.0.2"
+  nested:
+    dependency: transitive
+    description:
+      name: nested
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
   octo_image:
     dependency: transitive
     description:
@@ -654,7 +661,7 @@ packages:
       name: page_transition
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.7+6"
+    version: "2.0.2"
   path:
     dependency: transitive
     description:
@@ -770,7 +777,7 @@ packages:
       name: provider
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "3.2.0"
+    version: "6.0.0"
   quiver:
     dependency: "direct main"
     description:
@@ -778,13 +785,6 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "3.0.1"
-  random_color:
-    dependency: transitive
-    description:
-      name: random_color
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "1.0.5"
   receive_sharing_intent:
     dependency: "direct main"
     description:
@@ -1091,6 +1091,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.0"
+  video_thumbnail:
+    dependency: "direct main"
+    description:
+      name: video_thumbnail
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.4.3"
   visibility_detector:
     dependency: "direct main"
     description:

+ 7 - 6
pubspec.yaml

@@ -17,13 +17,13 @@ environment:
   sdk: ">=2.10.0 <3.0.0"
 
 dependencies:
-  animate_do: ^1.7.2
+  animate_do: ^2.0.0
   archive: ^3.1.2
   background_fetch: ^1.0.1
   cached_network_image: ^3.0.0
   chewie: ^1.0.0
-  computer: ^1.0.2
-  confetti: ^0.5.5
+  computer: ^2.0.0
+  confetti: ^0.6.0
   connectivity: ^3.0.3
   crisp:
     git: "https://github.com/kcrebound/flutter-crisp.git"
@@ -53,7 +53,7 @@ dependencies:
   flutter_sodium: ^0.2.0
   flutter_typeahead: ^3.2.0
   flutter_user_agent: ^1.2.2
-  flutter_windowmanager: ^0.0.2
+  flutter_windowmanager: ^0.2.0
   fluttercontactpicker: ^4.4.0
   fluttertoast: ^8.0.6
   google_nav_bar: ^5.0.5
@@ -72,7 +72,7 @@ dependencies:
   move_to_background: ^1.0.2
   open_file: ^3.2.1
   package_info_plus: ^1.0.1
-  page_transition: ^1.1.7+2
+  page_transition: ^2.0.2
   path_provider: ^2.0.1
   pedantic: ^1.9.2
   photo_manager:
@@ -81,7 +81,7 @@ dependencies:
   pie_chart:
     git: "https://github.com/apgapg/pie_chart.git"
   pinput: ^1.2.0
-  provider: ^3.1.0
+  provider: ^6.0.0
   quiver: ^3.0.1
   receive_sharing_intent: ^1.4.5
   scrollable_positioned_list: ^0.1.10
@@ -98,6 +98,7 @@ dependencies:
   url_launcher: ^6.0.3
   uuid: ^3.0.4
   video_player: ^2.0.0
+  video_thumbnail: ^0.4.3
   visibility_detector: ^0.2.0
   wallpaper_manager_flutter: ^0.0.2