From ce965ba5e1b71ac9e3ea98bdf7568d7f8830365a Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Wed, 20 May 2020 13:35:20 -0400 Subject: [PATCH] Soft delete feature (#890) * [Soft Delete] Added trash folder to mobile (#856) * [Soft Delete] Added trash folder to mobile * [Soft Delete] - Revert send to trash label Co-authored-by: Chad Scharf * [Soft Delete] - Fix for iOS autofill index behavior (#859) * [Soft Delete] Added trash folder to mobile * [Soft Delete] - Revert send to trash label * [Soft Delete] - iOS autofill index behavior fix Co-authored-by: Chad Scharf Co-authored-by: Chad Scharf --- src/App/Pages/Vault/AddEditPageViewModel.cs | 11 +-- src/App/Pages/Vault/CiphersPage.xaml.cs | 9 ++- src/App/Pages/Vault/CiphersPageViewModel.cs | 4 +- .../Vault/GroupingsPage/GroupingsPage.xaml.cs | 26 +++++-- .../GroupingsPage/GroupingsPageListItem.cs | 13 +++- .../GroupingsPage/GroupingsPageViewModel.cs | 45 +++++++++++-- src/App/Pages/Vault/ViewPage.xaml | 5 +- src/App/Pages/Vault/ViewPage.xaml.cs | 28 +++++++- src/App/Pages/Vault/ViewPageViewModel.cs | 62 +++++++++++++++-- src/App/Resources/AppResources.Designer.cs | 67 +++++++++++++++++++ src/App/Resources/AppResources.resx | 43 ++++++++++++ src/App/Utilities/AppHelpers.cs | 6 +- src/Core/Abstractions/IApiService.cs | 2 + src/Core/Abstractions/ICipherService.cs | 4 +- src/Core/Abstractions/ISearchService.cs | 6 +- src/Core/Enums/EventType.cs | 2 + src/Core/Models/Data/CipherData.cs | 2 + src/Core/Models/Domain/Cipher.cs | 6 +- src/Core/Models/Response/CipherResponse.cs | 1 + src/Core/Models/View/CipherView.cs | 3 + src/Core/Services/ApiService.cs | 10 +++ src/Core/Services/CipherService.cs | 48 +++++++++++++ src/Core/Services/SearchService.cs | 4 +- src/iOS.Core/Views/ExtensionTableSource.cs | 2 +- src/iOS/AppDelegate.cs | 9 ++- 25 files changed, 378 insertions(+), 40 deletions(-) diff --git a/src/App/Pages/Vault/AddEditPageViewModel.cs b/src/App/Pages/Vault/AddEditPageViewModel.cs index 92d3fd55f..bcc331e83 100644 --- a/src/App/Pages/Vault/AddEditPageViewModel.cs +++ b/src/App/Pages/Vault/AddEditPageViewModel.cs @@ -489,7 +489,8 @@ namespace Bit.App.Pages AppResources.InternetConnectionRequiredTitle); return false; } - var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToDelete, + var confirmed = await _platformUtilsService.ShowDialogAsync( + AppResources.DoYouReallyWantToSoftDeleteCipher, null, AppResources.Yes, AppResources.Cancel); if (!confirmed) { @@ -497,11 +498,11 @@ namespace Bit.App.Pages } try { - await _deviceActionService.ShowLoadingAsync(AppResources.Deleting); - await _cipherService.DeleteWithServerAsync(Cipher.Id); + await _deviceActionService.ShowLoadingAsync(AppResources.SoftDeleting); + await _cipherService.SoftDeleteWithServerAsync(Cipher.Id); await _deviceActionService.HideLoadingAsync(); - _platformUtilsService.ShowToast("success", null, AppResources.ItemDeleted); - _messagingService.Send("deletedCipher", Cipher); + _platformUtilsService.ShowToast("success", null, AppResources.ItemSoftDeleted); + _messagingService.Send("softDeletedCipher", Cipher); return true; } catch (ApiException e) diff --git a/src/App/Pages/Vault/CiphersPage.xaml.cs b/src/App/Pages/Vault/CiphersPage.xaml.cs index ee7253187..c019dd726 100644 --- a/src/App/Pages/Vault/CiphersPage.xaml.cs +++ b/src/App/Pages/Vault/CiphersPage.xaml.cs @@ -16,14 +16,19 @@ namespace Bit.App.Pages private bool _hasFocused; public CiphersPage(Func filter, bool folder = false, bool collection = false, - bool type = false, string autofillUrl = null) + bool type = false, string autofillUrl = null, bool deleted = false) { InitializeComponent(); _vm = BindingContext as CiphersPageViewModel; _vm.Page = this; _vm.Filter = filter; _vm.AutofillUrl = _autofillUrl = autofillUrl; - if (folder) + _vm.Deleted = deleted; + if (deleted) + { + _vm.PageTitle = AppResources.SearchTrash; + } + else if (folder) { _vm.PageTitle = AppResources.SearchFolder; } diff --git a/src/App/Pages/Vault/CiphersPageViewModel.cs b/src/App/Pages/Vault/CiphersPageViewModel.cs index e175fa3cb..1d3eb8434 100644 --- a/src/App/Pages/Vault/CiphersPageViewModel.cs +++ b/src/App/Pages/Vault/CiphersPageViewModel.cs @@ -44,6 +44,7 @@ namespace Bit.App.Pages public ExtendedObservableCollection Ciphers { get; set; } public Func Filter { get; set; } public string AutofillUrl { get; set; } + public bool Deleted { get; set; } public bool ShowNoData { @@ -105,7 +106,8 @@ namespace Bit.App.Pages } try { - ciphers = await _searchService.SearchCiphersAsync(searchText, Filter, null, cts.Token); + ciphers = await _searchService.SearchCiphersAsync(searchText, + Filter ?? (c => c.IsDeleted == Deleted), null, cts.Token); cts.Token.ThrowIfCancellationRequested(); } catch (OperationCanceledException) diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs index 70c9f24c5..f0a4fe425 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs @@ -28,7 +28,8 @@ namespace Bit.App.Pages private PreviousPageInfo _previousPage; public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = null, - string collectionId = null, string pageTitle = null, PreviousPageInfo previousPage = null) + string collectionId = null, string pageTitle = null, PreviousPageInfo previousPage = null, + bool deleted = false) { _pageName = string.Concat(nameof(GroupingsPage), "_", DateTime.UtcNow.Ticks); InitializeComponent(); @@ -47,6 +48,7 @@ namespace Bit.App.Pages _vm.Type = type; _vm.FolderId = folderId; _vm.CollectionId = collectionId; + _vm.Deleted = deleted; _previousPage = previousPage; if (pageTitle != null) { @@ -64,6 +66,11 @@ namespace Bit.App.Pages ToolbarItems.Add(_lockItem); ToolbarItems.Add(_exitItem); } + if (deleted) + { + _absLayout.Children.Remove(_fab); + ToolbarItems.Remove(_addItem); + } } public ExtendedListView ListView { get; set; } @@ -132,6 +139,7 @@ namespace Bit.App.Pages } } await ShowPreviousPageAsync(); + AdjustToolbar(); }, _mainContent); if (!_vm.MainPage) @@ -200,7 +208,11 @@ namespace Bit.App.Pages return; } - if (item.Cipher != null) + if (item.IsTrash) + { + await _vm.SelectTrashAsync(); + } + else if (item.Cipher != null) { await _vm.SelectCipherAsync(item.Cipher); } @@ -223,7 +235,7 @@ namespace Bit.App.Pages if (DoOnce()) { var page = new CiphersPage(_vm.Filter, _vm.FolderId != null, _vm.CollectionId != null, - _vm.Type != null); + _vm.Type != null, deleted: _vm.Deleted); await Navigation.PushModalAsync(new NavigationPage(page), false); } } @@ -245,7 +257,7 @@ namespace Bit.App.Pages private async void AddButton_Clicked(object sender, EventArgs e) { - if (DoOnce()) + if (!_vm.Deleted && DoOnce()) { var page = new AddEditPage(null, _vm.Type, _vm.FolderId, _vm.CollectionId); await Navigation.PushModalAsync(new NavigationPage(page)); @@ -268,5 +280,11 @@ namespace Bit.App.Pages } _previousPage = null; } + + private void AdjustToolbar() + { + _addItem.IsEnabled = !_vm.Deleted; + _addItem.IconImageSource = _vm.Deleted ? null : "plus.png"; + } } } diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs index a19c6ed69..e72df7fb1 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs @@ -15,6 +15,7 @@ namespace Bit.App.Pages public CipherType? Type { get; set; } public string ItemCount { get; set; } public bool FuzzyAutofill { get; set; } + public bool IsTrash { get; set; } public string Name { @@ -24,7 +25,11 @@ namespace Bit.App.Pages { return _name; } - if (Folder != null) + if (IsTrash) + { + _name = AppResources.Trash; + } + else if (Folder != null) { _name = Folder.Name; } @@ -64,7 +69,11 @@ namespace Bit.App.Pages { return _icon; } - if (Folder != null) + if (IsTrash) + { + _icon = "\uf014"; // fa-trash-o + } + else if (Folder != null) { _icon = Folder.Id == null ? "" : ""; } diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs index 01cf7e84e..94ac56580 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs @@ -32,6 +32,7 @@ namespace Bit.App.Pages private Dictionary _folderCounts = new Dictionary(); private Dictionary _collectionCounts = new Dictionary(); private Dictionary _typeCounts = new Dictionary(); + private int _deletedCount = 0; private readonly ICipherService _cipherService; private readonly IFolderService _folderService; @@ -73,6 +74,7 @@ namespace Bit.App.Pages public string FolderId { get; set; } public string CollectionId { get; set; } public Func Filter { get; set; } + public bool Deleted { get; set; } public bool HasCiphers { get; set; } public bool HasFolders { get; set; } @@ -152,7 +154,7 @@ namespace Bit.App.Pages ShowNoData = false; Loading = true; ShowList = false; - ShowAddCipherButton = true; + ShowAddCipherButton = !Deleted; var groupedItems = new List(); var page = Page as GroupingsPage; @@ -233,7 +235,8 @@ namespace Bit.App.Pages } if (Ciphers?.Any() ?? false) { - var ciphersListItems = Ciphers.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(); + var ciphersListItems = Ciphers.Where(c => c.IsDeleted == Deleted) + .Select(c => new GroupingsPageListItem { Cipher = c }).ToList(); groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items, ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any())); } @@ -244,6 +247,18 @@ namespace Bit.App.Pages groupedItems.Add(new GroupingsPageListGroup(noFolderCiphersListItems, AppResources.FolderNone, noFolderCiphersListItems.Count, uppercaseGroupNames, false)); } + // Ensure this is last in the list (appears at the bottom) + if (MainPage && !Deleted) + { + groupedItems.Add(new GroupingsPageListGroup(new List() + { + new GroupingsPageListItem() + { + IsTrash = true, + ItemCount = _deletedCount.ToString("N0") + } + }, AppResources.Trash, _deletedCount, uppercaseGroupNames, false)); + } GroupedItems.ResetWithRange(groupedItems); } finally @@ -299,6 +314,12 @@ namespace Bit.App.Pages await Page.Navigation.PushAsync(page); } + public async Task SelectTrashAsync() + { + var page = new GroupingsPage(false, null, null, null, AppResources.Trash, null, true); + await Page.Navigation.PushAsync(page); + } + public async Task ExitAsync() { var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.ExitConfirmation, @@ -344,6 +365,7 @@ namespace Bit.App.Pages HasFolders = false; HasCollections = false; Filter = null; + _deletedCount = 0; if (MainPage) { @@ -356,9 +378,14 @@ namespace Bit.App.Pages } else { - if (Type != null) + if (Deleted) { - Filter = c => c.Type == Type.Value; + Filter = c => c.IsDeleted; + NoDataText = AppResources.NoItemsTrash; + } + else if (Type != null) + { + Filter = c => c.Type == Type.Value && !c.IsDeleted; } else if (FolderId != null) { @@ -377,7 +404,7 @@ namespace Bit.App.Pages { PageTitle = AppResources.FolderNone; } - Filter = c => c.FolderId == folderId; + Filter = c => c.FolderId == folderId && !c.IsDeleted; } else if (CollectionId != null) { @@ -389,7 +416,7 @@ namespace Bit.App.Pages PageTitle = collectionNode.Node.Name; NestedCollections = (collectionNode.Children?.Count ?? 0) > 0 ? collectionNode.Children : null; } - Filter = c => c.CollectionIds?.Contains(CollectionId) ?? false; + Filter = c => c.CollectionIds?.Contains(CollectionId) ?? false && !c.IsDeleted; } else { @@ -402,6 +429,12 @@ namespace Bit.App.Pages { if (MainPage) { + if (c.IsDeleted) + { + _deletedCount++; + continue; + } + if (c.Favorite) { if (FavoriteCiphers == null) diff --git a/src/App/Pages/Vault/ViewPage.xaml b/src/App/Pages/Vault/ViewPage.xaml index ffb6b4059..1891576a6 100644 --- a/src/App/Pages/Vault/ViewPage.xaml +++ b/src/App/Pages/Vault/ViewPage.xaml @@ -32,7 +32,7 @@ Order="Secondary" /> - + AutomationProperties.Name="{u:I18n EditItem}" + IsVisible="{Binding CanEdit}"> diff --git a/src/App/Pages/Vault/ViewPage.xaml.cs b/src/App/Pages/Vault/ViewPage.xaml.cs index dce5e8f37..9272ada05 100644 --- a/src/App/Pages/Vault/ViewPage.xaml.cs +++ b/src/App/Pages/Vault/ViewPage.xaml.cs @@ -119,7 +119,17 @@ namespace Bit.App.Pages { if (DoOnce()) { - await Navigation.PushModalAsync(new NavigationPage(new AddEditPage(_vm.CipherId))); + if (_vm.IsDeleted) + { + if (await _vm.RestoreAsync()) + { + await Navigation.PopModalAsync(); + } + } + else + { + await Navigation.PushModalAsync(new NavigationPage(new AddEditPage(_vm.CipherId))); + } } } @@ -234,7 +244,17 @@ namespace Bit.App.Pages private void AdjustToolbar() { - if (Device.RuntimePlatform != Device.Android || _vm.Cipher == null) + if (_vm.Cipher == null) + { + return; + } + _editItem.Text = _vm.Cipher.IsDeleted ? AppResources.Restore : + AppResources.Edit; + if (_vm.Cipher.IsDeleted) + { + _absLayout.Children.Remove(_fab); + } + if (Device.RuntimePlatform != Device.Android) { return; } @@ -268,6 +288,10 @@ namespace Bit.App.Pages ToolbarItems.Insert(1, _collectionsItem); } } + if (_vm.Cipher.IsDeleted && !ToolbarItems.Contains(_editItem)) + { + ToolbarItems.Insert(1, _editItem); + } } } } diff --git a/src/App/Pages/Vault/ViewPageViewModel.cs b/src/App/Pages/Vault/ViewPageViewModel.cs index 31db301dd..deaeda538 100644 --- a/src/App/Pages/Vault/ViewPageViewModel.cs +++ b/src/App/Pages/Vault/ViewPageViewModel.cs @@ -86,6 +86,8 @@ namespace Bit.App.Pages nameof(PasswordUpdatedText), nameof(PasswordHistoryText), nameof(ShowIdentityAddress), + nameof(IsDeleted), + nameof(CanEdit), }); } public List Fields @@ -210,6 +212,8 @@ namespace Bit.App.Pages Page.Resources["textTotp"] = Application.Current.Resources[value ? "text-danger" : "text-default"]; } } + public bool IsDeleted => Cipher.IsDeleted; + public bool CanEdit => !Cipher.IsDeleted; public async Task LoadAsync(Action finishedLoadingAction = null) { @@ -282,7 +286,8 @@ namespace Bit.App.Pages AppResources.InternetConnectionRequiredTitle); return false; } - var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToDelete, + var confirmed = await _platformUtilsService.ShowDialogAsync( + Cipher.IsDeleted ? AppResources.DoYouReallyWantToPermanentlyDeleteCipher : AppResources.DoYouReallyWantToSoftDeleteCipher, null, AppResources.Yes, AppResources.Cancel); if (!confirmed) { @@ -290,11 +295,58 @@ namespace Bit.App.Pages } try { - await _deviceActionService.ShowLoadingAsync(AppResources.Deleting); - await _cipherService.DeleteWithServerAsync(Cipher.Id); + await _deviceActionService.ShowLoadingAsync(Cipher.IsDeleted ? AppResources.Deleting : AppResources.SoftDeleting); + if (Cipher.IsDeleted) + { + await _cipherService.DeleteWithServerAsync(Cipher.Id); + } + else + { + await _cipherService.SoftDeleteWithServerAsync(Cipher.Id); + } await _deviceActionService.HideLoadingAsync(); - _platformUtilsService.ShowToast("success", null, AppResources.ItemDeleted); - _messagingService.Send("deletedCipher", Cipher); + _platformUtilsService.ShowToast("success", null, + Cipher.IsDeleted ? AppResources.ItemDeleted : AppResources.ItemSoftDeleted); + _messagingService.Send(Cipher.IsDeleted ? "deletedCipher" : "softDeletedCipher", Cipher); + return true; + } + catch (ApiException e) + { + await _deviceActionService.HideLoadingAsync(); + if (e?.Error != null) + { + await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), + AppResources.AnErrorHasOccurred); + } + } + return false; + } + + public async Task RestoreAsync() + { + if (!IsDeleted) + { + return false; + } + if (Xamarin.Essentials.Connectivity.NetworkAccess == Xamarin.Essentials.NetworkAccess.None) + { + await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, + AppResources.InternetConnectionRequiredTitle); + return false; + } + var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToRestoreCipher, + null, AppResources.Yes, AppResources.Cancel); + if (!confirmed) + { + return false; + } + try + { + await _deviceActionService.ShowLoadingAsync(AppResources.Restoring); + await _cipherService.RestoreWithServerAsync(Cipher.Id); + await _deviceActionService.HideLoadingAsync(); + _platformUtilsService.ShowToast("success", null, AppResources.ItemRestored); + _messagingService.Send("restoredCipher", Cipher); return true; } catch (ApiException e) diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index 371fbc0f5..ca3aad762 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -10,6 +10,7 @@ namespace Bit.App.Resources { using System; + using System.Reflection; [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] @@ -1924,6 +1925,12 @@ namespace Bit.App.Resources { } } + public static string NoItemsTrash { + get { + return ResourceManager.GetString("NoItemsTrash", resourceCulture); + } + } + public static string AutofillAccessibilityService { get { return ResourceManager.GetString("AutofillAccessibilityService", resourceCulture); @@ -2871,5 +2878,65 @@ namespace Bit.App.Resources { return ResourceManager.GetString("AutofillTileUriNotFound", resourceCulture); } } + + public static string SoftDeleting { + get { + return ResourceManager.GetString("SoftDeleting", resourceCulture); + } + } + + public static string ItemSoftDeleted { + get { + return ResourceManager.GetString("ItemSoftDeleted", resourceCulture); + } + } + + public static string Restore { + get { + return ResourceManager.GetString("Restore", resourceCulture); + } + } + + public static string Restoring { + get { + return ResourceManager.GetString("Restoring", resourceCulture); + } + } + + public static string ItemRestored { + get { + return ResourceManager.GetString("ItemRestored", resourceCulture); + } + } + + public static string Trash { + get { + return ResourceManager.GetString("Trash", resourceCulture); + } + } + + public static string SearchTrash { + get { + return ResourceManager.GetString("SearchTrash", resourceCulture); + } + } + + public static string DoYouReallyWantToPermanentlyDeleteCipher { + get { + return ResourceManager.GetString("DoYouReallyWantToPermanentlyDeleteCipher", resourceCulture); + } + } + + public static string DoYouReallyWantToRestoreCipher { + get { + return ResourceManager.GetString("DoYouReallyWantToRestoreCipher", resourceCulture); + } + } + + public static string DoYouReallyWantToSoftDeleteCipher { + get { + return ResourceManager.GetString("DoYouReallyWantToSoftDeleteCipher", resourceCulture); + } + } } } diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index f819229ff..9b6efd7bf 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -1131,6 +1131,9 @@ There are no items in this folder. + + There are no items in the trash. + Auto-fill Accessibility Service @@ -1631,4 +1634,44 @@ No password fields detected + + Sending to trash... + Message shown when interacting with the server + + + Item has been sent to trash. + Confirmation message after successfully soft-deleting a login + + + Restore + Restores an entity (verb). + + + Restoring... + Message shown when interacting with the server + + + Item has been restored. + Confirmation message after successfully restoring a soft-deleted item + + + Trash + (noun) Location of deleted items which have not yet been permanently deleted + + + Search trash + (action prompt) Label for the search text field when viewing the trash folder + + + Do you really want to permanently delete? This cannot be undone. + Confirmation alert message when permanently deleteing a cipher. + + + Do you really want to restore this item? + Confirmation alert message when restoring a soft-deleted cipher. + + + Do you really want to send to the trash? + Confirmation alert message when soft-deleting a cipher. + \ No newline at end of file diff --git a/src/App/Utilities/AppHelpers.cs b/src/App/Utilities/AppHelpers.cs index f5dacd6c5..717ce871a 100644 --- a/src/App/Utilities/AppHelpers.cs +++ b/src/App/Utilities/AppHelpers.cs @@ -19,7 +19,11 @@ namespace Bit.App.Utilities var platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); var eventService = ServiceContainer.Resolve("eventService"); var lockService = ServiceContainer.Resolve("lockService"); - var options = new List { AppResources.View, AppResources.Edit }; + var options = new List { AppResources.View }; + if (!cipher.IsDeleted) + { + options.Add(AppResources.Edit); + } if (cipher.Type == Core.Enums.CipherType.Login) { if (!string.IsNullOrWhiteSpace(cipher.Login.Username)) diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index 54ee63451..9cc3140fb 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -37,6 +37,8 @@ namespace Bit.Core.Abstractions Task PutCipherCollectionsAsync(string id, CipherCollectionsRequest request); Task PutFolderAsync(string id, FolderRequest request); Task PutShareCipherAsync(string id, CipherShareRequest request); + Task PutDeleteCipherAsync(string id); + Task PutRestoreCipherAsync(string id); Task RefreshIdentityTokenAsync(); Task SendAsync(HttpMethod method, string path, TRequest body, bool authed, bool hasResponse); diff --git a/src/Core/Abstractions/ICipherService.cs b/src/Core/Abstractions/ICipherService.cs index ff6f31c09..c3cac5583 100644 --- a/src/Core/Abstractions/ICipherService.cs +++ b/src/Core/Abstractions/ICipherService.cs @@ -36,5 +36,7 @@ namespace Bit.Core.Abstractions Task UpsertAsync(CipherData cipher); Task UpsertAsync(List cipher); Task DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId); + Task SoftDeleteWithServerAsync(string id); + Task RestoreWithServerAsync(string id); } -} \ No newline at end of file +} diff --git a/src/Core/Abstractions/ISearchService.cs b/src/Core/Abstractions/ISearchService.cs index fae344bd8..ee7b52389 100644 --- a/src/Core/Abstractions/ISearchService.cs +++ b/src/Core/Abstractions/ISearchService.cs @@ -12,8 +12,8 @@ namespace Bit.Core.Abstractions Task IndexCiphersAsync(); bool IsSearchable(string query); Task> SearchCiphersAsync(string query, Func filter = null, - List ciphers = null, CancellationToken ct = default(CancellationToken)); + List ciphers = null, CancellationToken ct = default); List SearchCiphersBasic(List ciphers, string query, - CancellationToken ct = default(CancellationToken)); + CancellationToken ct = default, bool deleted = false); } -} \ No newline at end of file +} diff --git a/src/Core/Enums/EventType.cs b/src/Core/Enums/EventType.cs index d9411e31c..5c1ae9207 100644 --- a/src/Core/Enums/EventType.cs +++ b/src/Core/Enums/EventType.cs @@ -26,6 +26,8 @@ Cipher_ClientCopiedHiddenField = 1112, Cipher_ClientCopiedCardCode = 1113, Cipher_ClientAutofilled = 1114, + Cipher_SoftDeleted = 1115, + Cipher_Restored = 1116, Collection_Created = 1300, Collection_Updated = 1301, diff --git a/src/Core/Models/Data/CipherData.cs b/src/Core/Models/Data/CipherData.cs index 7e231f4fe..636e17963 100644 --- a/src/Core/Models/Data/CipherData.cs +++ b/src/Core/Models/Data/CipherData.cs @@ -45,6 +45,7 @@ namespace Bit.Core.Models.Data Fields = response.Fields?.Select(f => new FieldData(f)).ToList(); Attachments = response.Attachments?.Select(a => new AttachmentData(a)).ToList(); PasswordHistory = response.PasswordHistory?.Select(ph => new PasswordHistoryData(ph)).ToList(); + DeletedDate = response.DeletedDate; } public string Id { get; set; } @@ -66,5 +67,6 @@ namespace Bit.Core.Models.Data public List Attachments { get; set; } public List PasswordHistory { get; set; } public List CollectionIds { get; set; } + public DateTime? DeletedDate { get; set; } } } diff --git a/src/Core/Models/Domain/Cipher.cs b/src/Core/Models/Domain/Cipher.cs index efdffd27c..fce6b6d65 100644 --- a/src/Core/Models/Domain/Cipher.cs +++ b/src/Core/Models/Domain/Cipher.cs @@ -51,6 +51,7 @@ namespace Bit.Core.Models.Domain Attachments = obj.Attachments?.Select(a => new Attachment(a, alreadyEncrypted)).ToList(); Fields = obj.Fields?.Select(f => new Field(f, alreadyEncrypted)).ToList(); PasswordHistory = obj.PasswordHistory?.Select(ph => new PasswordHistory(ph, alreadyEncrypted)).ToList(); + DeletedDate = obj.DeletedDate; } public string Id { get; set; } @@ -73,6 +74,8 @@ namespace Bit.Core.Models.Domain public List PasswordHistory { get; set; } public HashSet CollectionIds { get; set; } + public DateTime? DeletedDate { get; set; } + public async Task DecryptAsync() { var model = new CipherView(this); @@ -161,7 +164,8 @@ namespace Bit.Core.Models.Domain Favorite = Favorite, RevisionDate = RevisionDate, Type = Type, - CollectionIds = CollectionIds.ToList() + CollectionIds = CollectionIds.ToList(), + DeletedDate = DeletedDate }; BuildDataModel(this, c, new HashSet { diff --git a/src/Core/Models/Response/CipherResponse.cs b/src/Core/Models/Response/CipherResponse.cs index a78c0ec1e..d6be67785 100644 --- a/src/Core/Models/Response/CipherResponse.cs +++ b/src/Core/Models/Response/CipherResponse.cs @@ -24,5 +24,6 @@ namespace Bit.Core.Models.Response public List Attachments { get; set; } public List PasswordHistory { get; set; } public List CollectionIds { get; set; } + public DateTime? DeletedDate { get; set; } } } diff --git a/src/Core/Models/View/CipherView.cs b/src/Core/Models/View/CipherView.cs index 5dd504441..44584f381 100644 --- a/src/Core/Models/View/CipherView.cs +++ b/src/Core/Models/View/CipherView.cs @@ -22,6 +22,7 @@ namespace Bit.Core.Models.View LocalData = c.LocalData; CollectionIds = c.CollectionIds; RevisionDate = c.RevisionDate; + DeletedDate = c.DeletedDate; } public string Id { get; set; } @@ -43,6 +44,7 @@ namespace Bit.Core.Models.View public List PasswordHistory { get; set; } public HashSet CollectionIds { get; set; } public DateTime RevisionDate { get; set; } + public DateTime? DeletedDate { get; set; } public string SubTitle @@ -96,5 +98,6 @@ namespace Bit.Core.Models.View return Login.PasswordRevisionDate; } } + public bool IsDeleted => DeletedDate.HasValue; } } diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index 046ffe084..72a98e60c 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -245,6 +245,16 @@ namespace Bit.Core.Services return SendAsync(HttpMethod.Delete, string.Concat("/ciphers/", id), null, true, false); } + public Task PutDeleteCipherAsync(string id) + { + return SendAsync(HttpMethod.Put, string.Concat("/ciphers/", id, "/delete"), null, true, false); + } + + public Task PutRestoreCipherAsync(string id) + { + return SendAsync(HttpMethod.Put, string.Concat("/ciphers/", id, "/restore"), null, true, false); + } + #endregion #region Attachments APIs diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index fd8ae51fa..8af29ed96 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -265,6 +265,10 @@ namespace Bit.Core.Services var ciphers = await GetAllDecryptedAsync(); return ciphers.Where(cipher => { + if (cipher.IsDeleted) + { + return false; + } if (folder && cipher.FolderId == groupingId) { return true; @@ -324,6 +328,11 @@ namespace Bit.Core.Services foreach (var cipher in ciphers) { + if (cipher.IsDeleted) + { + continue; + } + if (cipher.Type != CipherType.Login && (includeOtherTypes?.Any(t => t == cipher.Type) ?? false)) { others.Add(cipher); @@ -695,6 +704,45 @@ namespace Bit.Core.Services return null; } + public async Task SoftDeleteWithServerAsync(string id) + { + var userId = await _userService.GetUserIdAsync(); + var cipherKey = string.Format(Keys_CiphersFormat, userId); + var ciphers = await _storageService.GetAsync>(cipherKey); + if (ciphers == null) + { + return; + } + if (!ciphers.ContainsKey(id)) + { + return; + } + + await _apiService.PutDeleteCipherAsync(id); + ciphers[id].DeletedDate = DateTime.UtcNow; + await _storageService.SaveAsync(cipherKey, ciphers); + DecryptedCipherCache = null; + } + + public async Task RestoreWithServerAsync(string id) + { + var userId = await _userService.GetUserIdAsync(); + var cipherKey = string.Format(Keys_CiphersFormat, userId); + var ciphers = await _storageService.GetAsync>(cipherKey); + if (ciphers == null) + { + return; + } + if (!ciphers.ContainsKey(id)) + { + return; + } + await _apiService.PutRestoreCipherAsync(id); + ciphers[id].DeletedDate = null; + await _storageService.SaveAsync(cipherKey, ciphers); + DecryptedCipherCache = null; + } + // Helpers private async Task ShareAttachmentWithServerAsync(AttachmentView attachmentView, string cipherId, diff --git a/src/Core/Services/SearchService.cs b/src/Core/Services/SearchService.cs index 1f5447474..d71676a57 100644 --- a/src/Core/Services/SearchService.cs +++ b/src/Core/Services/SearchService.cs @@ -35,7 +35,7 @@ namespace Bit.Core.Services } public async Task> SearchCiphersAsync(string query, Func filter = null, - List ciphers = null, CancellationToken ct = default(CancellationToken)) + List ciphers = null, CancellationToken ct = default) { var results = new List(); if (query != null) @@ -68,7 +68,7 @@ namespace Bit.Core.Services } public List SearchCiphersBasic(List ciphers, string query, - CancellationToken ct = default(CancellationToken)) + CancellationToken ct = default, bool deleted = false) { ct.ThrowIfCancellationRequested(); query = query.Trim().ToLower(); diff --git a/src/iOS.Core/Views/ExtensionTableSource.cs b/src/iOS.Core/Views/ExtensionTableSource.cs index 4dac3d72e..8db1fc016 100644 --- a/src/iOS.Core/Views/ExtensionTableSource.cs +++ b/src/iOS.Core/Views/ExtensionTableSource.cs @@ -63,7 +63,7 @@ namespace Bit.iOS.Core.Views } _allItems = combinedLogins - .Where(c => c.Type == Bit.Core.Enums.CipherType.Login) + .Where(c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted) .Select(s => new CipherViewModel(s)) .ToList() ?? new List(); FilterResults(searchFilter, new CancellationToken()); diff --git a/src/iOS/AppDelegate.cs b/src/iOS/AppDelegate.cs index 98d09b15d..11f219893 100644 --- a/src/iOS/AppDelegate.cs +++ b/src/iOS/AppDelegate.cs @@ -120,7 +120,7 @@ namespace Bit.iOS } } } - else if (message.Command == "addedCipher" || message.Command == "editedCipher") + else if (message.Command == "addedCipher" || message.Command == "editedCipher" || message.Command == "restoredCipher") { if (_deviceActionService.SystemMajorVersion() >= 12) { @@ -142,7 +142,7 @@ namespace Bit.iOS await ASHelpers.ReplaceAllIdentities(); } } - else if (message.Command == "deletedCipher") + else if (message.Command == "deletedCipher" || message.Command == "softDeletedCipher") { if (_deviceActionService.SystemMajorVersion() >= 12) { @@ -168,6 +168,11 @@ namespace Bit.iOS await ASCredentialIdentityStore.SharedStore?.RemoveAllCredentialIdentitiesAsync(); } } + else if ((message.Command == "softDeletedCipher" || message.Command == "restoredCipher") + && _deviceActionService.SystemMajorVersion() >= 12) + { + await ASHelpers.ReplaceAllIdentities(); + } }); return base.FinishedLaunching(app, options);