delete_file_util.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import 'dart:async';
  2. import 'dart:io' as io;
  3. import 'dart:io';
  4. import 'dart:math';
  5. import 'package:device_info/device_info.dart';
  6. import 'package:flutter/cupertino.dart';
  7. import 'package:flutter/material.dart';
  8. import 'package:logging/logging.dart';
  9. import 'package:photo_manager/photo_manager.dart';
  10. import 'package:photos/core/constants.dart';
  11. import 'package:photos/core/event_bus.dart';
  12. import 'package:photos/db/files_db.dart';
  13. import 'package:photos/events/collection_updated_event.dart';
  14. import 'package:photos/events/files_updated_event.dart';
  15. import 'package:photos/events/local_photos_updated_event.dart';
  16. import 'package:photos/models/file.dart';
  17. import 'package:photos/models/selected_files.dart';
  18. import 'package:photos/models/trash_item_request.dart';
  19. import 'package:photos/services/remote_sync_service.dart';
  20. import 'package:photos/services/sync_service.dart';
  21. import 'package:photos/services/trash_sync_service.dart';
  22. import 'package:photos/ui/common/dialogs.dart';
  23. import 'package:photos/ui/common/linear_progress_dialog.dart';
  24. import 'package:photos/ui/components/action_sheet_widget.dart';
  25. import 'package:photos/ui/components/button_widget.dart';
  26. import 'package:photos/ui/components/models/button_type.dart';
  27. import 'package:photos/utils/dialog_util.dart';
  28. import 'package:photos/utils/file_util.dart';
  29. import 'package:photos/utils/toast_util.dart';
  30. final _logger = Logger("DeleteFileUtil");
  31. Future<void> deleteFilesFromEverywhere(
  32. BuildContext context,
  33. List<File> files,
  34. ) async {
  35. _logger.info("Trying to deleteFilesFromEverywhere " + files.toString());
  36. final List<String> localAssetIDs = [];
  37. final List<String> localSharedMediaIDs = [];
  38. final List<String> alreadyDeletedIDs = []; // to ignore already deleted files
  39. bool hasLocalOnlyFiles = false;
  40. for (final file in files) {
  41. if (file.localID != null) {
  42. if (!(await _localFileExist(file))) {
  43. _logger.warning("Already deleted " + file.toString());
  44. alreadyDeletedIDs.add(file.localID!);
  45. } else if (file.isSharedMediaToAppSandbox) {
  46. localSharedMediaIDs.add(file.localID!);
  47. } else {
  48. localAssetIDs.add(file.localID!);
  49. }
  50. }
  51. if (file.uploadedFileID == null) {
  52. hasLocalOnlyFiles = true;
  53. }
  54. }
  55. if (hasLocalOnlyFiles && Platform.isAndroid) {
  56. final shouldProceed = await shouldProceedWithDeletion(context);
  57. if (!shouldProceed) {
  58. return;
  59. }
  60. }
  61. Set<String> deletedIDs = <String>{};
  62. try {
  63. deletedIDs =
  64. (await PhotoManager.editor.deleteWithIds(localAssetIDs)).toSet();
  65. } catch (e, s) {
  66. _logger.severe("Could not delete file", e, s);
  67. }
  68. deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
  69. final updatedCollectionIDs = <int>{};
  70. final List<TrashRequest> uploadedFilesToBeTrashed = [];
  71. final List<File> deletedFiles = [];
  72. for (final file in files) {
  73. if (file.localID != null) {
  74. // Remove only those files that have already been removed from disk
  75. if (deletedIDs.contains(file.localID) ||
  76. alreadyDeletedIDs.contains(file.localID)) {
  77. deletedFiles.add(file);
  78. if (file.uploadedFileID != null) {
  79. uploadedFilesToBeTrashed
  80. .add(TrashRequest(file.uploadedFileID!, file.collectionID!));
  81. updatedCollectionIDs.add(file.collectionID!);
  82. } else {
  83. await FilesDB.instance.deleteLocalFile(file);
  84. }
  85. }
  86. } else {
  87. updatedCollectionIDs.add(file.collectionID!);
  88. deletedFiles.add(file);
  89. uploadedFilesToBeTrashed
  90. .add(TrashRequest(file.uploadedFileID!, file.collectionID!));
  91. }
  92. }
  93. if (uploadedFilesToBeTrashed.isNotEmpty) {
  94. try {
  95. final fileIDs =
  96. uploadedFilesToBeTrashed.map((item) => item.fileID).toList();
  97. await TrashSyncService.instance
  98. .trashFilesOnServer(uploadedFilesToBeTrashed);
  99. await FilesDB.instance.deleteMultipleUploadedFiles(fileIDs);
  100. } catch (e) {
  101. _logger.severe(e);
  102. showGenericErrorDialog(context: context);
  103. rethrow;
  104. }
  105. for (final collectionID in updatedCollectionIDs) {
  106. Bus.instance.fire(
  107. CollectionUpdatedEvent(
  108. collectionID,
  109. deletedFiles
  110. .where((file) => file.collectionID == collectionID)
  111. .toList(),
  112. "deleteFilesEverywhere",
  113. type: EventType.deletedFromEverywhere,
  114. ),
  115. );
  116. }
  117. }
  118. if (deletedFiles.isNotEmpty) {
  119. Bus.instance.fire(
  120. LocalPhotosUpdatedEvent(
  121. deletedFiles,
  122. type: EventType.deletedFromEverywhere,
  123. source: "deleteFilesEverywhere",
  124. ),
  125. );
  126. if (hasLocalOnlyFiles && Platform.isAndroid) {
  127. showShortToast(context, "Files deleted");
  128. } else {
  129. showShortToast(context, "Moved to trash");
  130. }
  131. }
  132. if (uploadedFilesToBeTrashed.isNotEmpty) {
  133. RemoteSyncService.instance.sync(silently: true);
  134. }
  135. }
  136. Future<void> deleteFilesFromRemoteOnly(
  137. BuildContext context,
  138. List<File> files,
  139. ) async {
  140. files.removeWhere((element) => element.uploadedFileID == null);
  141. if (files.isEmpty) {
  142. showToast(context, "Selected files are not on ente");
  143. return;
  144. }
  145. _logger.info(
  146. "Trying to deleteFilesFromRemoteOnly " +
  147. files.map((f) => f.uploadedFileID).toString(),
  148. );
  149. final updatedCollectionIDs = <int>{};
  150. final List<int> uploadedFileIDs = [];
  151. final List<TrashRequest> trashRequests = [];
  152. for (final file in files) {
  153. updatedCollectionIDs.add(file.collectionID!);
  154. uploadedFileIDs.add(file.uploadedFileID!);
  155. trashRequests.add(TrashRequest(file.uploadedFileID!, file.collectionID!));
  156. }
  157. try {
  158. await TrashSyncService.instance.trashFilesOnServer(trashRequests);
  159. await FilesDB.instance.deleteMultipleUploadedFiles(uploadedFileIDs);
  160. } catch (e, s) {
  161. _logger.severe("Failed to delete files from remote", e, s);
  162. showGenericErrorDialog(context: context);
  163. rethrow;
  164. }
  165. for (final collectionID in updatedCollectionIDs) {
  166. Bus.instance.fire(
  167. CollectionUpdatedEvent(
  168. collectionID,
  169. files.where((file) => file.collectionID == collectionID).toList(),
  170. "deleteFromRemoteOnly",
  171. type: EventType.deletedFromRemote,
  172. ),
  173. );
  174. }
  175. Bus.instance.fire(
  176. LocalPhotosUpdatedEvent(
  177. files,
  178. type: EventType.deletedFromRemote,
  179. source: "deleteFromRemoteOnly",
  180. ),
  181. );
  182. SyncService.instance.sync();
  183. RemoteSyncService.instance.sync(silently: true);
  184. }
  185. Future<void> deleteFilesOnDeviceOnly(
  186. BuildContext context,
  187. List<File> files,
  188. ) async {
  189. _logger.info("Trying to deleteFilesOnDeviceOnly" + files.toString());
  190. final List<String> localAssetIDs = [];
  191. final List<String> localSharedMediaIDs = [];
  192. final List<String> alreadyDeletedIDs = []; // to ignore already deleted files
  193. bool hasLocalOnlyFiles = false;
  194. for (final file in files) {
  195. if (file.localID != null) {
  196. if (!(await _localFileExist(file))) {
  197. _logger.warning("Already deleted " + file.toString());
  198. alreadyDeletedIDs.add(file.localID!);
  199. } else if (file.isSharedMediaToAppSandbox) {
  200. localSharedMediaIDs.add(file.localID!);
  201. } else {
  202. localAssetIDs.add(file.localID!);
  203. }
  204. }
  205. if (file.uploadedFileID == null) {
  206. hasLocalOnlyFiles = true;
  207. }
  208. }
  209. if (hasLocalOnlyFiles && Platform.isAndroid) {
  210. final shouldProceed = await shouldProceedWithDeletion(context);
  211. if (!shouldProceed) {
  212. return;
  213. }
  214. }
  215. Set<String> deletedIDs = <String>{};
  216. try {
  217. deletedIDs =
  218. (await PhotoManager.editor.deleteWithIds(localAssetIDs)).toSet();
  219. } catch (e, s) {
  220. _logger.severe("Could not delete file", e, s);
  221. }
  222. deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
  223. final List<File> deletedFiles = [];
  224. for (final file in files) {
  225. // Remove only those files that have been removed from disk
  226. if (deletedIDs.contains(file.localID) ||
  227. alreadyDeletedIDs.contains(file.localID)) {
  228. deletedFiles.add(file);
  229. file.localID = null;
  230. FilesDB.instance.update(file);
  231. }
  232. }
  233. if (deletedFiles.isNotEmpty || alreadyDeletedIDs.isNotEmpty) {
  234. Bus.instance.fire(
  235. LocalPhotosUpdatedEvent(
  236. deletedFiles,
  237. type: EventType.deletedFromDevice,
  238. source: "deleteFilesOnDeviceOnly",
  239. ),
  240. );
  241. }
  242. }
  243. Future<bool> deleteFromTrash(BuildContext context, List<File> files) async {
  244. final result = await showNewChoiceDialog(
  245. context,
  246. title: "Delete permanently",
  247. body: "This action cannot be undone",
  248. firstButtonLabel: "Delete",
  249. isCritical: true,
  250. firstButtonOnTap: () async {
  251. try {
  252. await TrashSyncService.instance.deleteFromTrash(files);
  253. Bus.instance.fire(
  254. FilesUpdatedEvent(
  255. files,
  256. type: EventType.deletedFromEverywhere,
  257. source: "deleteFromTrash",
  258. ),
  259. );
  260. } catch (e, s) {
  261. _logger.info("failed to delete from trash", e, s);
  262. rethrow;
  263. }
  264. },
  265. );
  266. if (result == ButtonAction.error) {
  267. await showGenericErrorDialog(context: context);
  268. return false;
  269. }
  270. if (result == null || result == ButtonAction.cancel) {
  271. return false;
  272. } else {
  273. return true;
  274. }
  275. }
  276. Future<bool> emptyTrash(BuildContext context) async {
  277. final result = await showNewChoiceDialog(
  278. context,
  279. title: "Empty trash",
  280. firstButtonLabel: "Empty",
  281. isCritical: true,
  282. firstButtonOnTap: () async {
  283. try {
  284. await TrashSyncService.instance.emptyTrash();
  285. } catch (e, s) {
  286. _logger.info("failed empty trash", e, s);
  287. rethrow;
  288. }
  289. },
  290. );
  291. if (result == ButtonAction.error) {
  292. await showGenericErrorDialog(context: context);
  293. return false;
  294. }
  295. if (result == null || result == ButtonAction.cancel) {
  296. return false;
  297. } else {
  298. return true;
  299. }
  300. }
  301. Future<bool> deleteLocalFiles(
  302. BuildContext context,
  303. List<String> localIDs,
  304. ) async {
  305. final List<String> deletedIDs = [];
  306. final List<String> localAssetIDs = [];
  307. final List<String> localSharedMediaIDs = [];
  308. for (String id in localIDs) {
  309. if (id.startsWith(oldSharedMediaIdentifier) ||
  310. id.startsWith(sharedMediaIdentifier)) {
  311. localSharedMediaIDs.add(id);
  312. } else {
  313. localAssetIDs.add(id);
  314. }
  315. }
  316. deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
  317. if (Platform.isAndroid) {
  318. final androidInfo = await DeviceInfoPlugin().androidInfo;
  319. if (androidInfo.version.sdkInt < android11SDKINT) {
  320. deletedIDs
  321. .addAll(await deleteLocalFilesInBatches(context, localAssetIDs));
  322. } else {
  323. deletedIDs
  324. .addAll(await _deleteLocalFilesInOneShot(context, localAssetIDs));
  325. }
  326. } else {
  327. deletedIDs.addAll(await _deleteLocalFilesInOneShot(context, localAssetIDs));
  328. }
  329. if (deletedIDs.isNotEmpty) {
  330. final deletedFiles = await FilesDB.instance.getLocalFiles(deletedIDs);
  331. await FilesDB.instance.deleteLocalFiles(deletedIDs);
  332. _logger.info(deletedFiles.length.toString() + " files deleted locally");
  333. Bus.instance.fire(
  334. LocalPhotosUpdatedEvent(deletedFiles, source: "deleteLocal"),
  335. );
  336. return true;
  337. } else {
  338. showToast(context, "Could not free up space");
  339. return false;
  340. }
  341. }
  342. Future<List<String>> _deleteLocalFilesInOneShot(
  343. BuildContext context,
  344. List<String> localIDs,
  345. ) async {
  346. _logger.info('starting _deleteLocalFilesInOneShot for ${localIDs.length}');
  347. final List<String> deletedIDs = [];
  348. final dialog = createProgressDialog(
  349. context,
  350. "Deleting " + localIDs.length.toString() + " backed up files...",
  351. );
  352. await dialog.show();
  353. try {
  354. deletedIDs.addAll(await PhotoManager.editor.deleteWithIds(localIDs));
  355. } catch (e, s) {
  356. _logger.severe("Could not delete files ", e, s);
  357. }
  358. _logger.info(
  359. '_deleteLocalFilesInOneShot deleted ${deletedIDs.length} out '
  360. 'of ${localIDs.length}',
  361. );
  362. await dialog.hide();
  363. return deletedIDs;
  364. }
  365. Future<List<String>> deleteLocalFilesInBatches(
  366. BuildContext context,
  367. List<String> localIDs,
  368. ) async {
  369. final dialogKey = GlobalKey<LinearProgressDialogState>();
  370. final dialog = LinearProgressDialog(
  371. "Deleting " + localIDs.length.toString() + " backed up files...",
  372. key: dialogKey,
  373. );
  374. showDialog(
  375. context: context,
  376. builder: (context) {
  377. return dialog;
  378. },
  379. barrierColor: Colors.black.withOpacity(0.85),
  380. );
  381. const minimumParts = 10;
  382. const minimumBatchSize = 1;
  383. const maximumBatchSize = 100;
  384. final batchSize = min(
  385. max(minimumBatchSize, (localIDs.length / minimumParts).round()),
  386. maximumBatchSize,
  387. );
  388. final List<String> deletedIDs = [];
  389. for (int index = 0; index < localIDs.length; index += batchSize) {
  390. if (dialogKey.currentState != null) {
  391. dialogKey.currentState!.setProgress(index / localIDs.length);
  392. }
  393. final ids = localIDs
  394. .getRange(index, min(localIDs.length, index + batchSize))
  395. .toList();
  396. _logger.info("Trying to delete " + ids.toString());
  397. try {
  398. deletedIDs.addAll(await PhotoManager.editor.deleteWithIds(ids));
  399. _logger.info("Deleted " + ids.toString());
  400. } catch (e, s) {
  401. _logger.severe("Could not delete batch " + ids.toString(), e, s);
  402. for (final id in ids) {
  403. try {
  404. deletedIDs.addAll(await PhotoManager.editor.deleteWithIds([id]));
  405. _logger.info("Deleted " + id);
  406. } catch (e, s) {
  407. _logger.severe("Could not delete file " + id, e, s);
  408. }
  409. }
  410. }
  411. }
  412. Navigator.of(dialogKey.currentContext!, rootNavigator: true).pop('dialog');
  413. return deletedIDs;
  414. }
  415. Future<bool> _localFileExist(File file) {
  416. if (file.isSharedMediaToAppSandbox) {
  417. final localFile = io.File(getSharedMediaFilePath(file));
  418. return localFile.exists();
  419. } else {
  420. return file.getAsset.then((asset) {
  421. if (asset == null) {
  422. return false;
  423. }
  424. return asset.exists;
  425. });
  426. }
  427. }
  428. Future<List<String>> _tryDeleteSharedMediaFiles(List<String> localIDs) {
  429. final List<String> actuallyDeletedIDs = [];
  430. try {
  431. return Future.forEach<String>(localIDs, (id) async {
  432. final String localPath = getSharedMediaPathFromLocalID(id);
  433. try {
  434. // verify the file exists as the OS may have already deleted it from cache
  435. if (io.File(localPath).existsSync()) {
  436. await io.File(localPath).delete();
  437. }
  438. actuallyDeletedIDs.add(id);
  439. } catch (e, s) {
  440. _logger.warning("Could not delete file " + id, e, s);
  441. // server log shouldn't contain localId
  442. _logger.severe("Could not delete file ", e, s);
  443. }
  444. }).then((ignore) {
  445. return actuallyDeletedIDs;
  446. });
  447. } catch (e, s) {
  448. _logger.severe("Unexpected error while deleting share media files", e, s);
  449. return Future.value(actuallyDeletedIDs);
  450. }
  451. }
  452. Future<bool> shouldProceedWithDeletion(BuildContext context) async {
  453. final choice = await showChoiceDialog(
  454. context,
  455. "Are you sure?",
  456. "Some of the files you are trying to delete are only available on your device and cannot be recovered if deleted",
  457. firstAction: "Cancel",
  458. secondAction: "Delete",
  459. secondActionColor: Colors.red,
  460. );
  461. return choice == DialogUserChoice.secondChoice;
  462. }
  463. Future<void> showDeleteSheet(
  464. BuildContext context,
  465. SelectedFiles selectedFiles,
  466. ) async {
  467. bool containsUploadedFile = false, containsLocalFile = false;
  468. for (final file in selectedFiles.files) {
  469. if (file.uploadedFileID != null) {
  470. debugPrint("${file.toString()} is uploaded");
  471. containsUploadedFile = true;
  472. }
  473. if (file.localID != null) {
  474. debugPrint("${file.toString()} has local");
  475. containsLocalFile = true;
  476. }
  477. }
  478. final List<ButtonWidget> buttons = [];
  479. final bool isBothLocalAndRemote = containsUploadedFile && containsLocalFile;
  480. final bool isLocalOnly = !containsUploadedFile;
  481. final bool isRemoteOnly = !containsLocalFile;
  482. final String? bodyHighlight =
  483. isBothLocalAndRemote ? "They will be deleted from all albums." : null;
  484. String body = "";
  485. if (isBothLocalAndRemote) {
  486. body = "Some items are in both ente and your device.";
  487. } else if (isRemoteOnly) {
  488. body = "Selected items will be deleted from all albums and moved to trash.";
  489. } else if (isLocalOnly) {
  490. body = "These items will be deleted from your device.";
  491. } else {
  492. throw AssertionError("Unexpected state");
  493. }
  494. // Add option to delete from ente
  495. if (isBothLocalAndRemote || isRemoteOnly) {
  496. buttons.add(
  497. ButtonWidget(
  498. labelText: isBothLocalAndRemote ? "Delete from ente" : "Yes, delete",
  499. buttonType: ButtonType.neutral,
  500. buttonSize: ButtonSize.large,
  501. shouldStickToDarkTheme: true,
  502. buttonAction: ButtonAction.first,
  503. shouldSurfaceExecutionStates: true,
  504. isInAlert: true,
  505. onTap: () async {
  506. await deleteFilesFromRemoteOnly(
  507. context,
  508. selectedFiles.files.toList(),
  509. );
  510. showShortToast(context, "Moved to trash");
  511. },
  512. ),
  513. );
  514. }
  515. // Add option to delete from local
  516. if (isBothLocalAndRemote || isLocalOnly) {
  517. buttons.add(
  518. ButtonWidget(
  519. labelText: isBothLocalAndRemote ? "Delete from device" : "Yes, delete",
  520. buttonType: ButtonType.neutral,
  521. buttonSize: ButtonSize.large,
  522. shouldStickToDarkTheme: true,
  523. buttonAction: ButtonAction.second,
  524. shouldSurfaceExecutionStates: false,
  525. isInAlert: true,
  526. onTap: () async {
  527. await deleteFilesOnDeviceOnly(context, selectedFiles.files.toList());
  528. },
  529. ),
  530. );
  531. }
  532. if (isBothLocalAndRemote) {
  533. buttons.add(
  534. ButtonWidget(
  535. labelText: "Delete from both",
  536. buttonType: ButtonType.neutral,
  537. buttonSize: ButtonSize.large,
  538. shouldStickToDarkTheme: true,
  539. buttonAction: ButtonAction.third,
  540. shouldSurfaceExecutionStates: true,
  541. isInAlert: true,
  542. onTap: () async {
  543. await deleteFilesFromEverywhere(
  544. context,
  545. selectedFiles.files.toList(),
  546. );
  547. // Navigator.of(context, rootNavigator: true).pop();
  548. // widget.onFileRemoved(file);
  549. },
  550. ),
  551. );
  552. }
  553. buttons.add(
  554. const ButtonWidget(
  555. labelText: "Cancel",
  556. buttonType: ButtonType.secondary,
  557. buttonSize: ButtonSize.large,
  558. shouldStickToDarkTheme: true,
  559. buttonAction: ButtonAction.fourth,
  560. isInAlert: true,
  561. ),
  562. );
  563. final ButtonAction? result = await showActionSheet(
  564. context: context,
  565. buttons: buttons,
  566. actionSheetType: ActionSheetType.defaultActionSheet,
  567. body: body,
  568. bodyHighlight: bodyHighlight,
  569. );
  570. if (result != null && result == ButtonAction.error) {
  571. showGenericErrorDialog(context: context);
  572. } else {
  573. selectedFiles.clearAll();
  574. }
  575. }