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:
commit
f00a04710b
10 changed files with 188 additions and 127 deletions
|
@ -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,
|
||||
|
|
2
mobile/lib/generated/intl/messages_en.dart
generated
2
mobile/lib/generated/intl/messages_en.dart
generated
|
@ -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"),
|
||||
|
|
10
mobile/lib/generated/l10n.dart
generated
10
mobile/lib/generated/l10n.dart
generated
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)''';
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"]);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue