123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- import 'dart:async';
- import 'dart:io';
- import 'dart:math';
- import 'package:flutter/material.dart';
- import 'package:logging/logging.dart';
- import 'package:photo_manager/photo_manager.dart';
- import 'package:photos/core/constants.dart';
- import 'package:photos/core/event_bus.dart';
- import 'package:photos/db/files_db.dart';
- import 'package:photos/events/collection_updated_event.dart';
- import 'package:photos/events/files_updated_event.dart';
- import "package:photos/events/force_reload_trash_page_event.dart";
- import 'package:photos/events/local_photos_updated_event.dart';
- import "package:photos/generated/l10n.dart";
- import 'package:photos/models/file/file.dart';
- import "package:photos/models/files_split.dart";
- import 'package:photos/models/selected_files.dart';
- import 'package:photos/models/trash_item_request.dart';
- import 'package:photos/services/remote_sync_service.dart';
- import 'package:photos/services/sync_service.dart';
- import 'package:photos/services/trash_sync_service.dart';
- import 'package:photos/ui/common/linear_progress_dialog.dart';
- import 'package:photos/ui/components/action_sheet_widget.dart';
- import 'package:photos/ui/components/buttons/button_widget.dart';
- import 'package:photos/ui/components/models/button_type.dart';
- import "package:photos/utils/device_info.dart";
- import 'package:photos/utils/dialog_util.dart';
- import 'package:photos/utils/file_util.dart';
- import 'package:photos/utils/toast_util.dart';
- final _logger = Logger("DeleteFileUtil");
- Future<void> deleteFilesFromEverywhere(
- BuildContext context,
- List<EnteFile> files,
- ) async {
- _logger.info("Trying to deleteFilesFromEverywhere " + files.toString());
- final List<String> localAssetIDs = [];
- final List<String> localSharedMediaIDs = [];
- final List<String> alreadyDeletedIDs = []; // to ignore already deleted files
- bool hasLocalOnlyFiles = false;
- for (final file in files) {
- if (file.localID != null) {
- if (!(await _localFileExist(file))) {
- _logger.warning("Already deleted " + file.toString());
- alreadyDeletedIDs.add(file.localID!);
- } else if (file.isSharedMediaToAppSandbox) {
- localSharedMediaIDs.add(file.localID!);
- } else {
- localAssetIDs.add(file.localID!);
- }
- }
- if (file.uploadedFileID == null) {
- hasLocalOnlyFiles = true;
- }
- }
- if (hasLocalOnlyFiles && Platform.isAndroid) {
- final shouldProceed = await shouldProceedWithDeletion(context);
- if (!shouldProceed) {
- return;
- }
- }
- Set<String> deletedIDs = <String>{};
- try {
- deletedIDs =
- (await PhotoManager.editor.deleteWithIds(localAssetIDs)).toSet();
- } catch (e, s) {
- _logger.severe("Could not delete file", e, s);
- }
- deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
- final updatedCollectionIDs = <int>{};
- final List<TrashRequest> uploadedFilesToBeTrashed = [];
- final List<EnteFile> deletedFiles = [];
- for (final file in files) {
- if (file.localID != null) {
- // Remove only those files that have already been removed from disk
- if (deletedIDs.contains(file.localID) ||
- alreadyDeletedIDs.contains(file.localID)) {
- deletedFiles.add(file);
- if (file.uploadedFileID != null) {
- uploadedFilesToBeTrashed
- .add(TrashRequest(file.uploadedFileID!, file.collectionID!));
- updatedCollectionIDs.add(file.collectionID!);
- } else {
- await FilesDB.instance.deleteLocalFile(file);
- }
- }
- } else {
- updatedCollectionIDs.add(file.collectionID!);
- deletedFiles.add(file);
- uploadedFilesToBeTrashed
- .add(TrashRequest(file.uploadedFileID!, file.collectionID!));
- }
- }
- if (uploadedFilesToBeTrashed.isNotEmpty) {
- try {
- final fileIDs =
- uploadedFilesToBeTrashed.map((item) => item.fileID).toList();
- await TrashSyncService.instance
- .trashFilesOnServer(uploadedFilesToBeTrashed);
- await FilesDB.instance.deleteMultipleUploadedFiles(fileIDs);
- } catch (e) {
- _logger.severe(e);
- await showGenericErrorDialog(context: context, error: e);
- rethrow;
- }
- for (final collectionID in updatedCollectionIDs) {
- Bus.instance.fire(
- CollectionUpdatedEvent(
- collectionID,
- deletedFiles
- .where((file) => file.collectionID == collectionID)
- .toList(),
- "deleteFilesEverywhere",
- type: EventType.deletedFromEverywhere,
- ),
- );
- }
- }
- if (deletedFiles.isNotEmpty) {
- Bus.instance.fire(
- LocalPhotosUpdatedEvent(
- deletedFiles,
- type: EventType.deletedFromEverywhere,
- source: "deleteFilesEverywhere",
- ),
- );
- if (hasLocalOnlyFiles && Platform.isAndroid) {
- showShortToast(context, S.of(context).filesDeleted);
- } else {
- showShortToast(context, S.of(context).movedToTrash);
- }
- }
- if (uploadedFilesToBeTrashed.isNotEmpty) {
- RemoteSyncService.instance.sync(silently: true);
- }
- }
- Future<void> deleteFilesFromRemoteOnly(
- BuildContext context,
- List<EnteFile> files,
- ) async {
- files.removeWhere((element) => element.uploadedFileID == null);
- if (files.isEmpty) {
- showToast(context, S.of(context).selectedFilesAreNotOnEnte);
- return;
- }
- _logger.info(
- "Trying to deleteFilesFromRemoteOnly " +
- files.map((f) => f.uploadedFileID).toString(),
- );
- final updatedCollectionIDs = <int>{};
- final List<int> uploadedFileIDs = [];
- final List<TrashRequest> trashRequests = [];
- for (final file in files) {
- updatedCollectionIDs.add(file.collectionID!);
- uploadedFileIDs.add(file.uploadedFileID!);
- trashRequests.add(TrashRequest(file.uploadedFileID!, file.collectionID!));
- }
- try {
- await TrashSyncService.instance.trashFilesOnServer(trashRequests);
- await FilesDB.instance.deleteMultipleUploadedFiles(uploadedFileIDs);
- } catch (e, s) {
- _logger.severe("Failed to delete files from remote", e, s);
- await showGenericErrorDialog(context: context, error: e);
- rethrow;
- }
- for (final collectionID in updatedCollectionIDs) {
- Bus.instance.fire(
- CollectionUpdatedEvent(
- collectionID,
- files.where((file) => file.collectionID == collectionID).toList(),
- "deleteFromRemoteOnly",
- type: EventType.deletedFromRemote,
- ),
- );
- }
- Bus.instance.fire(
- LocalPhotosUpdatedEvent(
- files,
- type: EventType.deletedFromRemote,
- source: "deleteFromRemoteOnly",
- ),
- );
- SyncService.instance.sync();
- RemoteSyncService.instance.sync(silently: true);
- }
- Future<void> deleteFilesOnDeviceOnly(
- BuildContext context,
- List<EnteFile> files,
- ) async {
- _logger.info("Trying to deleteFilesOnDeviceOnly" + files.toString());
- final List<String> localAssetIDs = [];
- final List<String> localSharedMediaIDs = [];
- final List<String> alreadyDeletedIDs = []; // to ignore already deleted files
- bool hasLocalOnlyFiles = false;
- for (final file in files) {
- if (file.localID != null) {
- if (!(await _localFileExist(file))) {
- _logger.warning("Already deleted " + file.toString());
- alreadyDeletedIDs.add(file.localID!);
- } else if (file.isSharedMediaToAppSandbox) {
- localSharedMediaIDs.add(file.localID!);
- } else {
- localAssetIDs.add(file.localID!);
- }
- }
- if (file.uploadedFileID == null) {
- hasLocalOnlyFiles = true;
- }
- }
- if (hasLocalOnlyFiles && Platform.isAndroid) {
- final shouldProceed = await shouldProceedWithDeletion(context);
- if (!shouldProceed) {
- return;
- }
- }
- Set<String> deletedIDs = <String>{};
- try {
- deletedIDs =
- (await PhotoManager.editor.deleteWithIds(localAssetIDs)).toSet();
- } catch (e, s) {
- _logger.severe("Could not delete file", e, s);
- }
- deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
- final List<EnteFile> deletedFiles = [];
- for (final file in files) {
- // Remove only those files that have been removed from disk
- if (deletedIDs.contains(file.localID) ||
- alreadyDeletedIDs.contains(file.localID)) {
- deletedFiles.add(file);
- file.localID = null;
- FilesDB.instance.update(file);
- }
- }
- if (deletedFiles.isNotEmpty || alreadyDeletedIDs.isNotEmpty) {
- Bus.instance.fire(
- LocalPhotosUpdatedEvent(
- deletedFiles,
- type: EventType.deletedFromDevice,
- source: "deleteFilesOnDeviceOnly",
- ),
- );
- }
- }
- Future<bool> deleteFromTrash(BuildContext context, List<EnteFile> files) async {
- bool didDeletionStart = false;
- final actionResult = await showChoiceActionSheet(
- context,
- title: S.of(context).permanentlyDelete,
- body: S.of(context).thisActionCannotBeUndone,
- firstButtonLabel: S.of(context).delete,
- isCritical: true,
- firstButtonOnTap: () async {
- try {
- didDeletionStart = true;
- await TrashSyncService.instance.deleteFromTrash(files);
- Bus.instance.fire(
- FilesUpdatedEvent(
- files,
- type: EventType.deletedFromEverywhere,
- source: "deleteFromTrash",
- ),
- );
- //the FilesUpdateEvent is not reloading trash on premanently removing
- //files, so need to fire ForceReloadTrashPageEvent
- Bus.instance.fire(ForceReloadTrashPageEvent());
- } catch (e, s) {
- _logger.info("failed to delete from trash", e, s);
- rethrow;
- }
- },
- );
- if (actionResult?.action == null ||
- actionResult!.action == ButtonAction.cancel) {
- return didDeletionStart ? true : false;
- } else if (actionResult.action == ButtonAction.error) {
- await showGenericErrorDialog(
- context: context,
- error: actionResult.exception,
- );
- return false;
- } else {
- return true;
- }
- }
- Future<bool> emptyTrash(BuildContext context) async {
- final actionResult = await showChoiceActionSheet(
- context,
- title: S.of(context).emptyTrash,
- body: S.of(context).permDeleteWarning,
- firstButtonLabel: S.of(context).empty,
- isCritical: true,
- firstButtonOnTap: () async {
- try {
- await TrashSyncService.instance.emptyTrash();
- } catch (e, s) {
- _logger.info("failed empty trash", e, s);
- rethrow;
- }
- },
- );
- if (actionResult?.action == null ||
- actionResult!.action == ButtonAction.cancel) {
- return false;
- } else if (actionResult.action == ButtonAction.error) {
- await showGenericErrorDialog(
- context: context,
- error: actionResult.exception,
- );
- return false;
- } else {
- return true;
- }
- }
- Future<bool> deleteLocalFiles(
- BuildContext context,
- List<String> localIDs,
- ) async {
- final List<String> deletedIDs = [];
- final List<String> localAssetIDs = [];
- final List<String> localSharedMediaIDs = [];
- for (String id in localIDs) {
- if (id.startsWith(oldSharedMediaIdentifier) ||
- id.startsWith(sharedMediaIdentifier)) {
- localSharedMediaIDs.add(id);
- } else {
- localAssetIDs.add(id);
- }
- }
- deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
- final bool shouldDeleteInBatches =
- await isAndroidSDKVersionLowerThan(android11SDKINT);
- if (shouldDeleteInBatches) {
- deletedIDs.addAll(await deleteLocalFilesInBatches(context, localAssetIDs));
- } else {
- deletedIDs.addAll(await _deleteLocalFilesInOneShot(context, localAssetIDs));
- }
- // In IOS, the library returns no error and fail to delete any file is
- // there's any shared file. As a stop-gap solution, we initiate deletion in
- // batches. Similar in Android, for large number of files, we have observed
- // that the library fails to delete any file. So, we initiate deletion in
- // batches.
- if (deletedIDs.isEmpty) {
- deletedIDs.addAll(
- await deleteLocalFilesInBatches(
- context,
- localAssetIDs,
- maximumBatchSize: 1000,
- minimumBatchSize: 10,
- ),
- );
- _logger
- .severe("iOS free-space fallback, deleted ${deletedIDs.length} files "
- "in batches}");
- }
- if (deletedIDs.isNotEmpty) {
- final deletedFiles = await FilesDB.instance.getLocalFiles(deletedIDs);
- await FilesDB.instance.deleteLocalFiles(deletedIDs);
- _logger.info(deletedFiles.length.toString() + " files deleted locally");
- Bus.instance.fire(
- LocalPhotosUpdatedEvent(deletedFiles, source: "deleteLocal"),
- );
- return true;
- } else {
- showToast(context, S.of(context).couldNotFreeUpSpace);
- return false;
- }
- }
- Future<List<String>> _deleteLocalFilesInOneShot(
- BuildContext context,
- List<String> localIDs,
- ) async {
- _logger.info('starting _deleteLocalFilesInOneShot for ${localIDs.length}');
- final List<String> deletedIDs = [];
- final dialog = createProgressDialog(
- context,
- "Deleting " + localIDs.length.toString() + " backed up files...",
- );
- await dialog.show();
- try {
- deletedIDs.addAll(await PhotoManager.editor.deleteWithIds(localIDs));
- } catch (e, s) {
- _logger.severe("Could not delete files ", e, s);
- }
- _logger.info(
- '_deleteLocalFilesInOneShot deleted ${deletedIDs.length} out '
- 'of ${localIDs.length}',
- );
- await dialog.hide();
- return deletedIDs;
- }
- Future<List<String>> deleteLocalFilesInBatches(
- BuildContext context,
- List<String> localIDs, {
- int minimumParts = 10,
- int minimumBatchSize = 1,
- int maximumBatchSize = 100,
- }) async {
- final dialogKey = GlobalKey<LinearProgressDialogState>();
- final dialog = LinearProgressDialog(
- "Deleting " + localIDs.length.toString() + " backed up files...",
- key: dialogKey,
- );
- showDialog(
- context: context,
- builder: (context) {
- return dialog;
- },
- barrierColor: Colors.black.withOpacity(0.85),
- );
- final batchSize = min(
- max(minimumBatchSize, (localIDs.length / minimumParts).round()),
- maximumBatchSize,
- );
- final List<String> deletedIDs = [];
- for (int index = 0; index < localIDs.length; index += batchSize) {
- if (dialogKey.currentState != null) {
- dialogKey.currentState!.setProgress(index / localIDs.length);
- }
- final ids = localIDs
- .getRange(index, min(localIDs.length, index + batchSize))
- .toList();
- _logger.info("Trying to delete " + ids.toString());
- try {
- deletedIDs.addAll(await PhotoManager.editor.deleteWithIds(ids));
- _logger.info("Deleted " + ids.toString());
- } catch (e, s) {
- _logger.severe("Could not delete batch " + ids.toString(), e, s);
- for (final id in ids) {
- try {
- deletedIDs.addAll(await PhotoManager.editor.deleteWithIds([id]));
- _logger.info("Deleted " + id);
- } catch (e, s) {
- _logger.severe("Could not delete file " + id, e, s);
- }
- }
- }
- }
- Navigator.of(dialogKey.currentContext!, rootNavigator: true).pop('dialog');
- return deletedIDs;
- }
- Future<bool> _localFileExist(EnteFile file) {
- if (file.isSharedMediaToAppSandbox) {
- final localFile = File(getSharedMediaFilePath(file));
- return localFile.exists();
- } else {
- return file.getAsset.then((asset) {
- if (asset == null) {
- return false;
- }
- return asset.exists;
- });
- }
- }
- Future<List<String>> _tryDeleteSharedMediaFiles(List<String> localIDs) {
- final List<String> actuallyDeletedIDs = [];
- try {
- return Future.forEach<String>(localIDs, (id) async {
- final String localPath = getSharedMediaPathFromLocalID(id);
- try {
- // verify the file exists as the OS may have already deleted it from cache
- if (File(localPath).existsSync()) {
- await File(localPath).delete();
- }
- actuallyDeletedIDs.add(id);
- } catch (e, s) {
- _logger.warning("Could not delete file " + id, e, s);
- // server log shouldn't contain localId
- _logger.severe("Could not delete file ", e, s);
- }
- }).then((ignore) {
- return actuallyDeletedIDs;
- });
- } catch (e, s) {
- _logger.severe("Unexpected error while deleting share media files", e, s);
- return Future.value(actuallyDeletedIDs);
- }
- }
- Future<bool> shouldProceedWithDeletion(BuildContext context) async {
- final actionResult = await showChoiceActionSheet(
- context,
- title: S.of(context).permanentlyDeleteFromDevice,
- body: S.of(context).someOfTheFilesYouAreTryingToDeleteAre,
- firstButtonLabel: S.of(context).delete,
- isCritical: true,
- );
- if (actionResult?.action == null) {
- return false;
- } else {
- return actionResult!.action == ButtonAction.first;
- }
- }
- Future<void> showDeleteSheet(
- BuildContext context,
- SelectedFiles selectedFiles,
- FilesSplit filesSplit,
- ) async {
- if (selectedFiles.files.length != filesSplit.count) {
- throw AssertionError("Unexpected state, #{selectedFiles.files.length} != "
- "${filesSplit.count}");
- }
- final List<EnteFile> deletableFiles =
- filesSplit.ownedByCurrentUser + filesSplit.pendingUploads;
- if (deletableFiles.isEmpty && filesSplit.ownedByOtherUsers.isNotEmpty) {
- showShortToast(context, S.of(context).cannotDeleteSharedFiles);
- return;
- }
- final containsUploadedFile = deletableFiles.any((f) => f.isUploaded);
- final containsLocalFile = deletableFiles.any((f) => f.localID != null);
- final List<ButtonWidget> buttons = [];
- final bool isBothLocalAndRemote = containsUploadedFile && containsLocalFile;
- final bool isLocalOnly = !containsUploadedFile;
- final bool isRemoteOnly = !containsLocalFile;
- final String? bodyHighlight = isBothLocalAndRemote
- ? S.of(context).theyWillBeDeletedFromAllAlbums
- : null;
- String body = "";
- if (isBothLocalAndRemote) {
- body = S.of(context).someItemsAreInBothEnteAndYourDevice;
- } else if (isRemoteOnly) {
- body = S.of(context).selectedItemsWillBeDeletedFromAllAlbumsAndMoved;
- } else if (isLocalOnly) {
- body = S.of(context).theseItemsWillBeDeletedFromYourDevice;
- } else {
- throw AssertionError("Unexpected state");
- }
- // Add option to delete from ente
- if (isBothLocalAndRemote || isRemoteOnly) {
- buttons.add(
- ButtonWidget(
- labelText: isBothLocalAndRemote
- ? S.of(context).deleteFromEnte
- : S.of(context).yesDelete,
- buttonType: ButtonType.neutral,
- buttonSize: ButtonSize.large,
- shouldStickToDarkTheme: true,
- buttonAction: ButtonAction.first,
- shouldSurfaceExecutionStates: true,
- isInAlert: true,
- onTap: () async {
- await deleteFilesFromRemoteOnly(
- context,
- deletableFiles,
- ).then(
- (value) {
- showShortToast(context, S.of(context).movedToTrash);
- },
- onError: (e, s) {
- showGenericErrorDialog(context: context, error: e);
- },
- );
- },
- ),
- );
- }
- // Add option to delete from local
- if (isBothLocalAndRemote || isLocalOnly) {
- buttons.add(
- ButtonWidget(
- labelText: isBothLocalAndRemote
- ? S.of(context).deleteFromDevice
- : S.of(context).yesDelete,
- buttonType: ButtonType.neutral,
- buttonSize: ButtonSize.large,
- shouldStickToDarkTheme: true,
- buttonAction: ButtonAction.second,
- shouldSurfaceExecutionStates: false,
- isInAlert: true,
- onTap: () async {
- await deleteFilesOnDeviceOnly(context, deletableFiles);
- },
- ),
- );
- }
- if (isBothLocalAndRemote) {
- buttons.add(
- ButtonWidget(
- labelText: S.of(context).deleteFromBoth,
- buttonType: ButtonType.neutral,
- buttonSize: ButtonSize.large,
- shouldStickToDarkTheme: true,
- buttonAction: ButtonAction.third,
- shouldSurfaceExecutionStates: true,
- isInAlert: true,
- onTap: () async {
- await deleteFilesFromEverywhere(
- context,
- deletableFiles,
- );
- },
- ),
- );
- }
- buttons.add(
- ButtonWidget(
- labelText: S.of(context).cancel,
- buttonType: ButtonType.secondary,
- buttonSize: ButtonSize.large,
- shouldStickToDarkTheme: true,
- buttonAction: ButtonAction.fourth,
- isInAlert: true,
- ),
- );
- final actionResult = await showActionSheet(
- context: context,
- buttons: buttons,
- actionSheetType: ActionSheetType.defaultActionSheet,
- body: body,
- bodyHighlight: bodyHighlight,
- );
- if (actionResult?.action != null &&
- actionResult!.action == ButtonAction.error) {
- await showGenericErrorDialog(
- context: context,
- error: actionResult.exception,
- );
- } else {
- selectedFiles.clearAll();
- }
- }
|