Add an option to download multiple items (#1563)

## Description

<img width="373" alt="Screenshot 2024-04-30 at 4 06 33 PM"
src="https://github.com/ente-io/ente/assets/1161789/f4bc463e-654d-4e5f-8d7d-27308149068b">

## Tests

- [x] Tested on Simulator

> Note: If the downloaded item was not owned by the user, but was shared
with them, it will get re-uploaded into the user's own account. This is
the existing behavior, so have left it untouched. Will wait for customer
feedback before updating the implementation to ignore such items.
This commit is contained in:
Vishnu Mohandas 2024-04-30 16:40:59 +05:30 committed by GitHub
commit f00a04710b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 188 additions and 127 deletions

View file

@ -455,6 +455,7 @@ class FilesDB {
}
Future<int> insert(EnteFile file) async {
_logger.info("Inserting $file");
final db = await instance.database;
return db.insert(
filesTable,

View file

@ -721,6 +721,8 @@ class MessageLookup extends MessageLookupByLibrary {
"filesBackedUpFromDevice": m22,
"filesBackedUpInAlbum": m23,
"filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
"filesSavedToGallery":
MessageLookupByLibrary.simpleMessage("Files saved to gallery"),
"flip": MessageLookupByLibrary.simpleMessage("Flip"),
"forYourMemories":
MessageLookupByLibrary.simpleMessage("for your memories"),

View file

@ -5945,6 +5945,16 @@ class S {
);
}
/// `Files saved to gallery`
String get filesSavedToGallery {
return Intl.message(
'Files saved to gallery',
name: 'filesSavedToGallery',
desc: '',
args: [],
);
}
/// `Failed to save file to gallery`
String get fileFailedToSaveToGallery {
return Intl.message(

View file

@ -835,6 +835,7 @@
"close": "Close",
"setAs": "Set as",
"fileSavedToGallery": "File saved to gallery",
"filesSavedToGallery": "Files saved to gallery",
"fileFailedToSaveToGallery": "Failed to save file to gallery",
"download": "Download",
"pressAndHoldToPlayVideo": "Press and hold to play video",

View file

@ -308,7 +308,7 @@ class EnteFile {
@override
String toString() {
return '''File(generatedID: $generatedID, localID: $localID, title: $title,
uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
}

View file

@ -3,6 +3,7 @@ import "dart:async";
import 'package:fast_base58/fast_base58.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import "package:logging/logging.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
import 'package:photos/core/configuration.dart';
import "package:photos/generated/l10n.dart";
@ -30,6 +31,8 @@ import 'package:photos/ui/sharing/manage_links_widget.dart';
import "package:photos/ui/tools/collage/collage_creator_page.dart";
import "package:photos/ui/viewer/location/update_location_data_widget.dart";
import 'package:photos/utils/delete_file_util.dart';
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/file_download_util.dart";
import 'package:photos/utils/magic_util.dart';
import 'package:photos/utils/navigation_util.dart';
import "package:photos/utils/share_util.dart";
@ -56,6 +59,7 @@ class FileSelectionActionsWidget extends StatefulWidget {
class _FileSelectionActionsWidgetState
extends State<FileSelectionActionsWidget> {
static final _logger = Logger("FileSelectionActionsWidget");
late int currentUserID;
late FilesSplit split;
late CollectionActions collectionActions;
@ -115,6 +119,8 @@ class _FileSelectionActionsWidgetState
!widget.selectedFiles.files.any(
(element) => element.fileType == FileType.video,
);
final showDownloadOption =
widget.selectedFiles.files.any((element) => element.localID == null);
//To animate adding and removing of [SelectedActionButton], add all items
//and set [shouldShow] to false for items that should not be shown and true
@ -367,6 +373,16 @@ class _FileSelectionActionsWidgetState
);
}
if (showDownloadOption) {
items.add(
SelectionActionButton(
labelText: S.of(context).download,
icon: Icons.cloud_download_outlined,
onTap: () => _download(widget.selectedFiles.files.toList()),
),
);
}
items.add(
SelectionActionButton(
labelText: S.of(context).share,
@ -379,41 +395,36 @@ class _FileSelectionActionsWidgetState
),
);
if (items.isNotEmpty) {
final scrollController = ScrollController();
// h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
return MediaQuery(
data: MediaQuery.of(context).removePadding(removeBottom: true),
child: SafeArea(
child: Scrollbar(
radius: const Radius.circular(1),
thickness: 2,
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(
decelerationRate: ScrollDecelerationRate.fast,
),
scrollDirection: Axis.horizontal,
child: Container(
padding: const EdgeInsets.only(bottom: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 4),
...items,
const SizedBox(width: 4),
],
),
final scrollController = ScrollController();
// h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
return MediaQuery(
data: MediaQuery.of(context).removePadding(removeBottom: true),
child: SafeArea(
child: Scrollbar(
radius: const Radius.circular(1),
thickness: 2,
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(
decelerationRate: ScrollDecelerationRate.fast,
),
scrollDirection: Axis.horizontal,
child: Container(
padding: const EdgeInsets.only(bottom: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 4),
...items,
const SizedBox(width: 4),
],
),
),
),
),
);
} else {
// TODO: Return "Select All" here
return const SizedBox.shrink();
}
),
);
}
Future<void> _moveFiles() async {
@ -647,4 +658,29 @@ class _FileSelectionActionsWidgetState
widget.selectedFiles.clearAll();
}
}
Future<void> _download(List<EnteFile> files) async {
final dialog = createProgressDialog(
context,
S.of(context).downloading,
isDismissible: true,
);
await dialog.show();
try {
final futures = <Future>[];
for (final file in files) {
if (file.localID == null) {
futures.add(downloadToGallery(file));
}
}
await Future.wait(futures);
await dialog.hide();
widget.selectedFiles.clearAll();
showToast(context, S.of(context).filesSavedToGallery);
} catch (e) {
_logger.warning("Failed to save files", e);
await dialog.hide();
await showGenericErrorDialog(context: context, error: e);
}
}
}

View file

@ -4,30 +4,23 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:media_extension/media_extension.dart';
import 'package:path/path.dart' as file_path;
import 'package:photo_manager/photo_manager.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/db/files_db.dart';
import 'package:photos/events/local_photos_updated_event.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/l10n/l10n.dart";
import "package:photos/models/file/extensions/file_props.dart";
import 'package:photos/models/file/file.dart';
import 'package:photos/models/file/file_type.dart';
import 'package:photos/models/file/trash_file.dart';
import 'package:photos/models/ignored_file.dart';
import "package:photos/models/metadata/common_keys.dart";
import 'package:photos/models/selected_files.dart';
import "package:photos/service_locator.dart";
import 'package:photos/services/collections_service.dart';
import 'package:photos/services/hidden_service.dart';
import 'package:photos/services/ignored_files_service.dart';
import 'package:photos/services/local_sync_service.dart';
import 'package:photos/ui/collections/collection_action_sheet.dart';
import 'package:photos/ui/viewer/file/custom_app_bar.dart';
import "package:photos/ui/viewer/file_details/favorite_widget.dart";
import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
import 'package:photos/utils/dialog_util.dart';
import "package:photos/utils/file_download_util.dart";
import 'package:photos/utils/file_util.dart';
import "package:photos/utils/magic_util.dart";
import 'package:photos/utils/toast_util.dart';
@ -165,7 +158,7 @@ class FileAppBarState extends State<FileAppBar> {
Icon(
Platform.isAndroid
? Icons.download
: CupertinoIcons.cloud_download,
: Icons.cloud_download_outlined,
color: Theme.of(context).iconTheme.color,
),
const Padding(
@ -330,98 +323,16 @@ class FileAppBarState extends State<FileAppBar> {
);
await dialog.show();
try {
final FileType type = file.fileType;
final bool downloadLivePhotoOnDroid =
type == FileType.livePhoto && Platform.isAndroid;
AssetEntity? savedAsset;
final File? fileToSave = await getFile(file);
//Disabling notifications for assets changing to insert the file into
//files db before triggering a sync.
await PhotoManager.stopChangeNotify();
if (type == FileType.image) {
savedAsset = await PhotoManager.editor
.saveImageWithPath(fileToSave!.path, title: file.title!);
} else if (type == FileType.video) {
savedAsset = await PhotoManager.editor
.saveVideo(fileToSave!, title: file.title!);
} else if (type == FileType.livePhoto) {
final File? liveVideoFile =
await getFileFromServer(file, liveVideo: true);
if (liveVideoFile == null) {
throw AssertionError("Live video can not be null");
}
if (downloadLivePhotoOnDroid) {
await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
} else {
savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: fileToSave!,
videoFile: liveVideoFile,
title: file.title!,
);
}
}
if (savedAsset != null) {
file.localID = savedAsset.id;
await FilesDB.instance.insert(file);
Bus.instance.fire(
LocalPhotosUpdatedEvent(
[file],
source: "download",
),
);
} else if (!downloadLivePhotoOnDroid && savedAsset == null) {
_logger.severe('Failed to save assert of type $type');
}
await downloadToGallery(file);
showToast(context, S.of(context).fileSavedToGallery);
await dialog.hide();
} catch (e) {
_logger.warning("Failed to save file", e);
await dialog.hide();
await showGenericErrorDialog(context: context, error: e);
} finally {
await PhotoManager.startChangeNotify();
LocalSyncService.instance.checkAndSync().ignore();
}
}
Future<void> _saveLivePhotoOnDroid(
File image,
File video,
EnteFile enteFile,
) async {
debugPrint("Downloading LivePhoto on Droid");
AssetEntity? savedAsset = await (PhotoManager.editor
.saveImageWithPath(image.path, title: enteFile.title!));
if (savedAsset == null) {
throw Exception("Failed to save image of live photo");
}
IgnoredFile ignoreVideoFile = IgnoredFile(
savedAsset.id,
savedAsset.title ?? '',
savedAsset.relativePath ?? 'remoteDownload',
"remoteDownload",
);
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
file_path.extension(video.path);
savedAsset = (await (PhotoManager.editor.saveVideo(
video,
title: videoTitle,
)));
if (savedAsset == null) {
throw Exception("Failed to save video of live photo");
}
ignoreVideoFile = IgnoredFile(
savedAsset.id,
savedAsset.title ?? videoTitle,
savedAsset.relativePath ?? 'remoteDownload',
"remoteDownload",
);
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
}
Future<void> _setAs(EnteFile file) async {
final dialog = createProgressDialog(context, S.of(context).pleaseWait);
await dialog.show();

View file

@ -4,14 +4,23 @@ import "package:computer/computer.dart";
import 'package:dio/dio.dart';
import "package:flutter/foundation.dart";
import 'package:logging/logging.dart';
import 'package:path/path.dart' as file_path;
import "package:photo_manager/photo_manager.dart";
import 'package:photos/core/configuration.dart';
import "package:photos/core/event_bus.dart";
import 'package:photos/core/network/network.dart';
import "package:photos/db/files_db.dart";
import "package:photos/events/local_photos_updated_event.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/models/file/file_type.dart";
import "package:photos/models/ignored_file.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/ignored_files_service.dart";
import "package:photos/services/local_sync_service.dart";
import 'package:photos/utils/crypto_util.dart';
import "package:photos/utils/data_util.dart";
import "package:photos/utils/fake_progress.dart";
import "package:photos/utils/file_util.dart";
final _logger = Logger("file_download_util");
@ -115,6 +124,97 @@ Future<Uint8List> getFileKeyUsingBgWorker(EnteFile file) async {
);
}
Future<void> downloadToGallery(EnteFile file) async {
try {
final FileType type = file.fileType;
final bool downloadLivePhotoOnDroid =
type == FileType.livePhoto && Platform.isAndroid;
AssetEntity? savedAsset;
final File? fileToSave = await getFile(file);
//Disabling notifications for assets changing to insert the file into
//files db before triggering a sync.
await PhotoManager.stopChangeNotify();
if (type == FileType.image) {
savedAsset = await PhotoManager.editor
.saveImageWithPath(fileToSave!.path, title: file.title!);
} else if (type == FileType.video) {
savedAsset =
await PhotoManager.editor.saveVideo(fileToSave!, title: file.title!);
} else if (type == FileType.livePhoto) {
final File? liveVideoFile =
await getFileFromServer(file, liveVideo: true);
if (liveVideoFile == null) {
throw AssertionError("Live video can not be null");
}
if (downloadLivePhotoOnDroid) {
await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
} else {
savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: fileToSave!,
videoFile: liveVideoFile,
title: file.title!,
);
}
}
if (savedAsset != null) {
file.localID = savedAsset.id;
await FilesDB.instance.insert(file);
Bus.instance.fire(
LocalPhotosUpdatedEvent(
[file],
source: "download",
),
);
} else if (!downloadLivePhotoOnDroid && savedAsset == null) {
_logger.severe('Failed to save assert of type $type');
}
} catch (e) {
_logger.severe("Failed to save file", e);
rethrow;
} finally {
await PhotoManager.startChangeNotify();
LocalSyncService.instance.checkAndSync().ignore();
}
}
Future<void> _saveLivePhotoOnDroid(
File image,
File video,
EnteFile enteFile,
) async {
debugPrint("Downloading LivePhoto on Droid");
AssetEntity? savedAsset = await (PhotoManager.editor
.saveImageWithPath(image.path, title: enteFile.title!));
if (savedAsset == null) {
throw Exception("Failed to save image of live photo");
}
IgnoredFile ignoreVideoFile = IgnoredFile(
savedAsset.id,
savedAsset.title ?? '',
savedAsset.relativePath ?? 'remoteDownload',
"remoteDownload",
);
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
file_path.extension(video.path);
savedAsset = (await (PhotoManager.editor.saveVideo(
video,
title: videoTitle,
)));
if (savedAsset == null) {
throw Exception("Failed to save video of live photo");
}
ignoreVideoFile = IgnoredFile(
savedAsset.id,
savedAsset.title ?? videoTitle,
savedAsset.relativePath ?? 'remoteDownload',
"remoteDownload",
);
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
}
Uint8List _decryptFileKey(Map<String, dynamic> args) {
final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);

View file

@ -342,10 +342,10 @@ packages:
dependency: "direct main"
description:
name: cupertino_icons
sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.6"
version: "1.0.8"
dart_style:
dependency: transitive
description:

View file

@ -39,7 +39,7 @@ dependencies:
connectivity_plus: ^6.0.2
cross_file: ^0.3.3
crypto: ^3.0.2
cupertino_icons: ^1.0.0
cupertino_icons: ^1.0.8
defer_pointer: ^0.0.2
device_info_plus: ^9.0.3
dio: ^4.0.6