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 <cscharf@users.noreply.github.com>

* [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 <cscharf@users.noreply.github.com>

Co-authored-by: Chad Scharf <cscharf@users.noreply.github.com>
This commit is contained in:
Chad Scharf 2020-05-20 13:35:20 -04:00 committed by GitHub
parent 4b9a036e5e
commit ce965ba5e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 378 additions and 40 deletions

View File

@ -489,7 +489,8 @@ namespace Bit.App.Pages
AppResources.InternetConnectionRequiredTitle); AppResources.InternetConnectionRequiredTitle);
return false; return false;
} }
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToDelete, var confirmed = await _platformUtilsService.ShowDialogAsync(
AppResources.DoYouReallyWantToSoftDeleteCipher,
null, AppResources.Yes, AppResources.Cancel); null, AppResources.Yes, AppResources.Cancel);
if (!confirmed) if (!confirmed)
{ {
@ -497,11 +498,11 @@ namespace Bit.App.Pages
} }
try try
{ {
await _deviceActionService.ShowLoadingAsync(AppResources.Deleting); await _deviceActionService.ShowLoadingAsync(AppResources.SoftDeleting);
await _cipherService.DeleteWithServerAsync(Cipher.Id); await _cipherService.SoftDeleteWithServerAsync(Cipher.Id);
await _deviceActionService.HideLoadingAsync(); await _deviceActionService.HideLoadingAsync();
_platformUtilsService.ShowToast("success", null, AppResources.ItemDeleted); _platformUtilsService.ShowToast("success", null, AppResources.ItemSoftDeleted);
_messagingService.Send("deletedCipher", Cipher); _messagingService.Send("softDeletedCipher", Cipher);
return true; return true;
} }
catch (ApiException e) catch (ApiException e)

View File

@ -16,14 +16,19 @@ namespace Bit.App.Pages
private bool _hasFocused; private bool _hasFocused;
public CiphersPage(Func<CipherView, bool> filter, bool folder = false, bool collection = false, public CiphersPage(Func<CipherView, bool> filter, bool folder = false, bool collection = false,
bool type = false, string autofillUrl = null) bool type = false, string autofillUrl = null, bool deleted = false)
{ {
InitializeComponent(); InitializeComponent();
_vm = BindingContext as CiphersPageViewModel; _vm = BindingContext as CiphersPageViewModel;
_vm.Page = this; _vm.Page = this;
_vm.Filter = filter; _vm.Filter = filter;
_vm.AutofillUrl = _autofillUrl = autofillUrl; _vm.AutofillUrl = _autofillUrl = autofillUrl;
if (folder) _vm.Deleted = deleted;
if (deleted)
{
_vm.PageTitle = AppResources.SearchTrash;
}
else if (folder)
{ {
_vm.PageTitle = AppResources.SearchFolder; _vm.PageTitle = AppResources.SearchFolder;
} }

View File

@ -44,6 +44,7 @@ namespace Bit.App.Pages
public ExtendedObservableCollection<CipherView> Ciphers { get; set; } public ExtendedObservableCollection<CipherView> Ciphers { get; set; }
public Func<CipherView, bool> Filter { get; set; } public Func<CipherView, bool> Filter { get; set; }
public string AutofillUrl { get; set; } public string AutofillUrl { get; set; }
public bool Deleted { get; set; }
public bool ShowNoData public bool ShowNoData
{ {
@ -105,7 +106,8 @@ namespace Bit.App.Pages
} }
try 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(); cts.Token.ThrowIfCancellationRequested();
} }
catch (OperationCanceledException) catch (OperationCanceledException)

View File

@ -28,7 +28,8 @@ namespace Bit.App.Pages
private PreviousPageInfo _previousPage; private PreviousPageInfo _previousPage;
public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = null, 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); _pageName = string.Concat(nameof(GroupingsPage), "_", DateTime.UtcNow.Ticks);
InitializeComponent(); InitializeComponent();
@ -47,6 +48,7 @@ namespace Bit.App.Pages
_vm.Type = type; _vm.Type = type;
_vm.FolderId = folderId; _vm.FolderId = folderId;
_vm.CollectionId = collectionId; _vm.CollectionId = collectionId;
_vm.Deleted = deleted;
_previousPage = previousPage; _previousPage = previousPage;
if (pageTitle != null) if (pageTitle != null)
{ {
@ -64,6 +66,11 @@ namespace Bit.App.Pages
ToolbarItems.Add(_lockItem); ToolbarItems.Add(_lockItem);
ToolbarItems.Add(_exitItem); ToolbarItems.Add(_exitItem);
} }
if (deleted)
{
_absLayout.Children.Remove(_fab);
ToolbarItems.Remove(_addItem);
}
} }
public ExtendedListView ListView { get; set; } public ExtendedListView ListView { get; set; }
@ -132,6 +139,7 @@ namespace Bit.App.Pages
} }
} }
await ShowPreviousPageAsync(); await ShowPreviousPageAsync();
AdjustToolbar();
}, _mainContent); }, _mainContent);
if (!_vm.MainPage) if (!_vm.MainPage)
@ -200,7 +208,11 @@ namespace Bit.App.Pages
return; return;
} }
if (item.Cipher != null) if (item.IsTrash)
{
await _vm.SelectTrashAsync();
}
else if (item.Cipher != null)
{ {
await _vm.SelectCipherAsync(item.Cipher); await _vm.SelectCipherAsync(item.Cipher);
} }
@ -223,7 +235,7 @@ namespace Bit.App.Pages
if (DoOnce()) if (DoOnce())
{ {
var page = new CiphersPage(_vm.Filter, _vm.FolderId != null, _vm.CollectionId != null, 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); await Navigation.PushModalAsync(new NavigationPage(page), false);
} }
} }
@ -245,7 +257,7 @@ namespace Bit.App.Pages
private async void AddButton_Clicked(object sender, EventArgs e) 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); var page = new AddEditPage(null, _vm.Type, _vm.FolderId, _vm.CollectionId);
await Navigation.PushModalAsync(new NavigationPage(page)); await Navigation.PushModalAsync(new NavigationPage(page));
@ -268,5 +280,11 @@ namespace Bit.App.Pages
} }
_previousPage = null; _previousPage = null;
} }
private void AdjustToolbar()
{
_addItem.IsEnabled = !_vm.Deleted;
_addItem.IconImageSource = _vm.Deleted ? null : "plus.png";
}
} }
} }

View File

@ -15,6 +15,7 @@ namespace Bit.App.Pages
public CipherType? Type { get; set; } public CipherType? Type { get; set; }
public string ItemCount { get; set; } public string ItemCount { get; set; }
public bool FuzzyAutofill { get; set; } public bool FuzzyAutofill { get; set; }
public bool IsTrash { get; set; }
public string Name public string Name
{ {
@ -24,7 +25,11 @@ namespace Bit.App.Pages
{ {
return _name; return _name;
} }
if (Folder != null) if (IsTrash)
{
_name = AppResources.Trash;
}
else if (Folder != null)
{ {
_name = Folder.Name; _name = Folder.Name;
} }
@ -64,7 +69,11 @@ namespace Bit.App.Pages
{ {
return _icon; return _icon;
} }
if (Folder != null) if (IsTrash)
{
_icon = "\uf014"; // fa-trash-o
}
else if (Folder != null)
{ {
_icon = Folder.Id == null ? "" : ""; _icon = Folder.Id == null ? "" : "";
} }

View File

@ -32,6 +32,7 @@ namespace Bit.App.Pages
private Dictionary<string, int> _folderCounts = new Dictionary<string, int>(); private Dictionary<string, int> _folderCounts = new Dictionary<string, int>();
private Dictionary<string, int> _collectionCounts = new Dictionary<string, int>(); private Dictionary<string, int> _collectionCounts = new Dictionary<string, int>();
private Dictionary<CipherType, int> _typeCounts = new Dictionary<CipherType, int>(); private Dictionary<CipherType, int> _typeCounts = new Dictionary<CipherType, int>();
private int _deletedCount = 0;
private readonly ICipherService _cipherService; private readonly ICipherService _cipherService;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
@ -73,6 +74,7 @@ namespace Bit.App.Pages
public string FolderId { get; set; } public string FolderId { get; set; }
public string CollectionId { get; set; } public string CollectionId { get; set; }
public Func<CipherView, bool> Filter { get; set; } public Func<CipherView, bool> Filter { get; set; }
public bool Deleted { get; set; }
public bool HasCiphers { get; set; } public bool HasCiphers { get; set; }
public bool HasFolders { get; set; } public bool HasFolders { get; set; }
@ -152,7 +154,7 @@ namespace Bit.App.Pages
ShowNoData = false; ShowNoData = false;
Loading = true; Loading = true;
ShowList = false; ShowList = false;
ShowAddCipherButton = true; ShowAddCipherButton = !Deleted;
var groupedItems = new List<GroupingsPageListGroup>(); var groupedItems = new List<GroupingsPageListGroup>();
var page = Page as GroupingsPage; var page = Page as GroupingsPage;
@ -233,7 +235,8 @@ namespace Bit.App.Pages
} }
if (Ciphers?.Any() ?? false) 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, groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any())); ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
} }
@ -244,6 +247,18 @@ namespace Bit.App.Pages
groupedItems.Add(new GroupingsPageListGroup(noFolderCiphersListItems, AppResources.FolderNone, groupedItems.Add(new GroupingsPageListGroup(noFolderCiphersListItems, AppResources.FolderNone,
noFolderCiphersListItems.Count, uppercaseGroupNames, false)); noFolderCiphersListItems.Count, uppercaseGroupNames, false));
} }
// Ensure this is last in the list (appears at the bottom)
if (MainPage && !Deleted)
{
groupedItems.Add(new GroupingsPageListGroup(new List<GroupingsPageListItem>()
{
new GroupingsPageListItem()
{
IsTrash = true,
ItemCount = _deletedCount.ToString("N0")
}
}, AppResources.Trash, _deletedCount, uppercaseGroupNames, false));
}
GroupedItems.ResetWithRange(groupedItems); GroupedItems.ResetWithRange(groupedItems);
} }
finally finally
@ -299,6 +314,12 @@ namespace Bit.App.Pages
await Page.Navigation.PushAsync(page); 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() public async Task ExitAsync()
{ {
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.ExitConfirmation, var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.ExitConfirmation,
@ -344,6 +365,7 @@ namespace Bit.App.Pages
HasFolders = false; HasFolders = false;
HasCollections = false; HasCollections = false;
Filter = null; Filter = null;
_deletedCount = 0;
if (MainPage) if (MainPage)
{ {
@ -356,9 +378,14 @@ namespace Bit.App.Pages
} }
else 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) else if (FolderId != null)
{ {
@ -377,7 +404,7 @@ namespace Bit.App.Pages
{ {
PageTitle = AppResources.FolderNone; PageTitle = AppResources.FolderNone;
} }
Filter = c => c.FolderId == folderId; Filter = c => c.FolderId == folderId && !c.IsDeleted;
} }
else if (CollectionId != null) else if (CollectionId != null)
{ {
@ -389,7 +416,7 @@ namespace Bit.App.Pages
PageTitle = collectionNode.Node.Name; PageTitle = collectionNode.Node.Name;
NestedCollections = (collectionNode.Children?.Count ?? 0) > 0 ? collectionNode.Children : null; 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 else
{ {
@ -402,6 +429,12 @@ namespace Bit.App.Pages
{ {
if (MainPage) if (MainPage)
{ {
if (c.IsDeleted)
{
_deletedCount++;
continue;
}
if (c.Favorite) if (c.Favorite)
{ {
if (FavoriteCiphers == null) if (FavoriteCiphers == null)

View File

@ -32,7 +32,7 @@
Order="Secondary" /> Order="Secondary" />
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1" <ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1"
x:Name="_closeItem" x:Key="closeItem" /> x:Name="_closeItem" x:Key="closeItem" />
<ToolbarItem Text="{u:I18n Edit}" Clicked="EditToolbarItem_Clicked" Order="Primary" <ToolbarItem Clicked="EditToolbarItem_Clicked" Order="Primary"
x:Name="_editItem" x:Key="editItem" /> x:Name="_editItem" x:Key="editItem" />
<ToolbarItem Icon="more_vert.png" Clicked="More_Clicked" Order="Primary" <ToolbarItem Icon="more_vert.png" Clicked="More_Clicked" Order="Primary"
x:Name="_moreItem" x:Key="moreItem" x:Name="_moreItem" x:Key="moreItem"
@ -670,7 +670,8 @@
AbsoluteLayout.LayoutFlags="PositionProportional" AbsoluteLayout.LayoutFlags="PositionProportional"
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize" AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize"
AutomationProperties.IsInAccessibleTree="True" AutomationProperties.IsInAccessibleTree="True"
AutomationProperties.Name="{u:I18n EditItem}"> AutomationProperties.Name="{u:I18n EditItem}"
IsVisible="{Binding CanEdit}">
<Button.Effects> <Button.Effects>
<effects:FabShadowEffect /> <effects:FabShadowEffect />
</Button.Effects> </Button.Effects>

View File

@ -119,7 +119,17 @@ namespace Bit.App.Pages
{ {
if (DoOnce()) 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() 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; return;
} }
@ -268,6 +288,10 @@ namespace Bit.App.Pages
ToolbarItems.Insert(1, _collectionsItem); ToolbarItems.Insert(1, _collectionsItem);
} }
} }
if (_vm.Cipher.IsDeleted && !ToolbarItems.Contains(_editItem))
{
ToolbarItems.Insert(1, _editItem);
}
} }
} }
} }

View File

@ -86,6 +86,8 @@ namespace Bit.App.Pages
nameof(PasswordUpdatedText), nameof(PasswordUpdatedText),
nameof(PasswordHistoryText), nameof(PasswordHistoryText),
nameof(ShowIdentityAddress), nameof(ShowIdentityAddress),
nameof(IsDeleted),
nameof(CanEdit),
}); });
} }
public List<ViewPageFieldViewModel> Fields public List<ViewPageFieldViewModel> Fields
@ -210,6 +212,8 @@ namespace Bit.App.Pages
Page.Resources["textTotp"] = Application.Current.Resources[value ? "text-danger" : "text-default"]; Page.Resources["textTotp"] = Application.Current.Resources[value ? "text-danger" : "text-default"];
} }
} }
public bool IsDeleted => Cipher.IsDeleted;
public bool CanEdit => !Cipher.IsDeleted;
public async Task<bool> LoadAsync(Action finishedLoadingAction = null) public async Task<bool> LoadAsync(Action finishedLoadingAction = null)
{ {
@ -282,7 +286,8 @@ namespace Bit.App.Pages
AppResources.InternetConnectionRequiredTitle); AppResources.InternetConnectionRequiredTitle);
return false; 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); null, AppResources.Yes, AppResources.Cancel);
if (!confirmed) if (!confirmed)
{ {
@ -290,11 +295,58 @@ namespace Bit.App.Pages
} }
try try
{ {
await _deviceActionService.ShowLoadingAsync(AppResources.Deleting); await _deviceActionService.ShowLoadingAsync(Cipher.IsDeleted ? AppResources.Deleting : AppResources.SoftDeleting);
await _cipherService.DeleteWithServerAsync(Cipher.Id); if (Cipher.IsDeleted)
{
await _cipherService.DeleteWithServerAsync(Cipher.Id);
}
else
{
await _cipherService.SoftDeleteWithServerAsync(Cipher.Id);
}
await _deviceActionService.HideLoadingAsync(); await _deviceActionService.HideLoadingAsync();
_platformUtilsService.ShowToast("success", null, AppResources.ItemDeleted); _platformUtilsService.ShowToast("success", null,
_messagingService.Send("deletedCipher", Cipher); 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<bool> 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; return true;
} }
catch (ApiException e) catch (ApiException e)

View File

@ -10,6 +10,7 @@
namespace Bit.App.Resources { namespace Bit.App.Resources {
using System; using System;
using System.Reflection;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [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 { public static string AutofillAccessibilityService {
get { get {
return ResourceManager.GetString("AutofillAccessibilityService", resourceCulture); return ResourceManager.GetString("AutofillAccessibilityService", resourceCulture);
@ -2871,5 +2878,65 @@ namespace Bit.App.Resources {
return ResourceManager.GetString("AutofillTileUriNotFound", resourceCulture); 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);
}
}
} }
} }

View File

@ -1131,6 +1131,9 @@
<data name="NoItemsFolder" xml:space="preserve"> <data name="NoItemsFolder" xml:space="preserve">
<value>There are no items in this folder.</value> <value>There are no items in this folder.</value>
</data> </data>
<data name="NoItemsTrash" xml:space="preserve">
<value>There are no items in the trash.</value>
</data>
<data name="AutofillAccessibilityService" xml:space="preserve"> <data name="AutofillAccessibilityService" xml:space="preserve">
<value>Auto-fill Accessibility Service</value> <value>Auto-fill Accessibility Service</value>
</data> </data>
@ -1631,4 +1634,44 @@
<data name="AutofillTileUriNotFound" xml:space="preserve"> <data name="AutofillTileUriNotFound" xml:space="preserve">
<value>No password fields detected</value> <value>No password fields detected</value>
</data> </data>
<data name="SoftDeleting" xml:space="preserve">
<value>Sending to trash...</value>
<comment>Message shown when interacting with the server</comment>
</data>
<data name="ItemSoftDeleted" xml:space="preserve">
<value>Item has been sent to trash.</value>
<comment>Confirmation message after successfully soft-deleting a login</comment>
</data>
<data name="Restore" xml:space="preserve">
<value>Restore</value>
<comment>Restores an entity (verb).</comment>
</data>
<data name="Restoring" xml:space="preserve">
<value>Restoring...</value>
<comment>Message shown when interacting with the server</comment>
</data>
<data name="ItemRestored" xml:space="preserve">
<value>Item has been restored.</value>
<comment>Confirmation message after successfully restoring a soft-deleted item</comment>
</data>
<data name="Trash" xml:space="preserve">
<value>Trash</value>
<comment>(noun) Location of deleted items which have not yet been permanently deleted</comment>
</data>
<data name="SearchTrash" xml:space="preserve">
<value>Search trash</value>
<comment>(action prompt) Label for the search text field when viewing the trash folder</comment>
</data>
<data name="DoYouReallyWantToPermanentlyDeleteCipher" xml:space="preserve">
<value>Do you really want to permanently delete? This cannot be undone.</value>
<comment>Confirmation alert message when permanently deleteing a cipher.</comment>
</data>
<data name="DoYouReallyWantToRestoreCipher" xml:space="preserve">
<value>Do you really want to restore this item?</value>
<comment>Confirmation alert message when restoring a soft-deleted cipher.</comment>
</data>
<data name="DoYouReallyWantToSoftDeleteCipher" xml:space="preserve">
<value>Do you really want to send to the trash?</value>
<comment>Confirmation alert message when soft-deleting a cipher.</comment>
</data>
</root> </root>

View File

@ -19,7 +19,11 @@ namespace Bit.App.Utilities
var platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"); var platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
var eventService = ServiceContainer.Resolve<IEventService>("eventService"); var eventService = ServiceContainer.Resolve<IEventService>("eventService");
var lockService = ServiceContainer.Resolve<ILockService>("lockService"); var lockService = ServiceContainer.Resolve<ILockService>("lockService");
var options = new List<string> { AppResources.View, AppResources.Edit }; var options = new List<string> { AppResources.View };
if (!cipher.IsDeleted)
{
options.Add(AppResources.Edit);
}
if (cipher.Type == Core.Enums.CipherType.Login) if (cipher.Type == Core.Enums.CipherType.Login)
{ {
if (!string.IsNullOrWhiteSpace(cipher.Login.Username)) if (!string.IsNullOrWhiteSpace(cipher.Login.Username))

View File

@ -37,6 +37,8 @@ namespace Bit.Core.Abstractions
Task PutCipherCollectionsAsync(string id, CipherCollectionsRequest request); Task PutCipherCollectionsAsync(string id, CipherCollectionsRequest request);
Task<FolderResponse> PutFolderAsync(string id, FolderRequest request); Task<FolderResponse> PutFolderAsync(string id, FolderRequest request);
Task<CipherResponse> PutShareCipherAsync(string id, CipherShareRequest request); Task<CipherResponse> PutShareCipherAsync(string id, CipherShareRequest request);
Task PutDeleteCipherAsync(string id);
Task PutRestoreCipherAsync(string id);
Task RefreshIdentityTokenAsync(); Task RefreshIdentityTokenAsync();
Task<TResponse> SendAsync<TRequest, TResponse>(HttpMethod method, string path, Task<TResponse> SendAsync<TRequest, TResponse>(HttpMethod method, string path,
TRequest body, bool authed, bool hasResponse); TRequest body, bool authed, bool hasResponse);

View File

@ -36,5 +36,7 @@ namespace Bit.Core.Abstractions
Task UpsertAsync(CipherData cipher); Task UpsertAsync(CipherData cipher);
Task UpsertAsync(List<CipherData> cipher); Task UpsertAsync(List<CipherData> cipher);
Task<byte[]> DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId); Task<byte[]> DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId);
Task SoftDeleteWithServerAsync(string id);
Task RestoreWithServerAsync(string id);
} }
} }

View File

@ -12,8 +12,8 @@ namespace Bit.Core.Abstractions
Task IndexCiphersAsync(); Task IndexCiphersAsync();
bool IsSearchable(string query); bool IsSearchable(string query);
Task<List<CipherView>> SearchCiphersAsync(string query, Func<CipherView, bool> filter = null, Task<List<CipherView>> SearchCiphersAsync(string query, Func<CipherView, bool> filter = null,
List<CipherView> ciphers = null, CancellationToken ct = default(CancellationToken)); List<CipherView> ciphers = null, CancellationToken ct = default);
List<CipherView> SearchCiphersBasic(List<CipherView> ciphers, string query, List<CipherView> SearchCiphersBasic(List<CipherView> ciphers, string query,
CancellationToken ct = default(CancellationToken)); CancellationToken ct = default, bool deleted = false);
} }
} }

View File

@ -26,6 +26,8 @@
Cipher_ClientCopiedHiddenField = 1112, Cipher_ClientCopiedHiddenField = 1112,
Cipher_ClientCopiedCardCode = 1113, Cipher_ClientCopiedCardCode = 1113,
Cipher_ClientAutofilled = 1114, Cipher_ClientAutofilled = 1114,
Cipher_SoftDeleted = 1115,
Cipher_Restored = 1116,
Collection_Created = 1300, Collection_Created = 1300,
Collection_Updated = 1301, Collection_Updated = 1301,

View File

@ -45,6 +45,7 @@ namespace Bit.Core.Models.Data
Fields = response.Fields?.Select(f => new FieldData(f)).ToList(); Fields = response.Fields?.Select(f => new FieldData(f)).ToList();
Attachments = response.Attachments?.Select(a => new AttachmentData(a)).ToList(); Attachments = response.Attachments?.Select(a => new AttachmentData(a)).ToList();
PasswordHistory = response.PasswordHistory?.Select(ph => new PasswordHistoryData(ph)).ToList(); PasswordHistory = response.PasswordHistory?.Select(ph => new PasswordHistoryData(ph)).ToList();
DeletedDate = response.DeletedDate;
} }
public string Id { get; set; } public string Id { get; set; }
@ -66,5 +67,6 @@ namespace Bit.Core.Models.Data
public List<AttachmentData> Attachments { get; set; } public List<AttachmentData> Attachments { get; set; }
public List<PasswordHistoryData> PasswordHistory { get; set; } public List<PasswordHistoryData> PasswordHistory { get; set; }
public List<string> CollectionIds { get; set; } public List<string> CollectionIds { get; set; }
public DateTime? DeletedDate { get; set; }
} }
} }

View File

@ -51,6 +51,7 @@ namespace Bit.Core.Models.Domain
Attachments = obj.Attachments?.Select(a => new Attachment(a, alreadyEncrypted)).ToList(); Attachments = obj.Attachments?.Select(a => new Attachment(a, alreadyEncrypted)).ToList();
Fields = obj.Fields?.Select(f => new Field(f, alreadyEncrypted)).ToList(); Fields = obj.Fields?.Select(f => new Field(f, alreadyEncrypted)).ToList();
PasswordHistory = obj.PasswordHistory?.Select(ph => new PasswordHistory(ph, alreadyEncrypted)).ToList(); PasswordHistory = obj.PasswordHistory?.Select(ph => new PasswordHistory(ph, alreadyEncrypted)).ToList();
DeletedDate = obj.DeletedDate;
} }
public string Id { get; set; } public string Id { get; set; }
@ -73,6 +74,8 @@ namespace Bit.Core.Models.Domain
public List<PasswordHistory> PasswordHistory { get; set; } public List<PasswordHistory> PasswordHistory { get; set; }
public HashSet<string> CollectionIds { get; set; } public HashSet<string> CollectionIds { get; set; }
public DateTime? DeletedDate { get; set; }
public async Task<CipherView> DecryptAsync() public async Task<CipherView> DecryptAsync()
{ {
var model = new CipherView(this); var model = new CipherView(this);
@ -161,7 +164,8 @@ namespace Bit.Core.Models.Domain
Favorite = Favorite, Favorite = Favorite,
RevisionDate = RevisionDate, RevisionDate = RevisionDate,
Type = Type, Type = Type,
CollectionIds = CollectionIds.ToList() CollectionIds = CollectionIds.ToList(),
DeletedDate = DeletedDate
}; };
BuildDataModel(this, c, new HashSet<string> BuildDataModel(this, c, new HashSet<string>
{ {

View File

@ -24,5 +24,6 @@ namespace Bit.Core.Models.Response
public List<AttachmentResponse> Attachments { get; set; } public List<AttachmentResponse> Attachments { get; set; }
public List<PasswordHistoryResponse> PasswordHistory { get; set; } public List<PasswordHistoryResponse> PasswordHistory { get; set; }
public List<string> CollectionIds { get; set; } public List<string> CollectionIds { get; set; }
public DateTime? DeletedDate { get; set; }
} }
} }

View File

@ -22,6 +22,7 @@ namespace Bit.Core.Models.View
LocalData = c.LocalData; LocalData = c.LocalData;
CollectionIds = c.CollectionIds; CollectionIds = c.CollectionIds;
RevisionDate = c.RevisionDate; RevisionDate = c.RevisionDate;
DeletedDate = c.DeletedDate;
} }
public string Id { get; set; } public string Id { get; set; }
@ -43,6 +44,7 @@ namespace Bit.Core.Models.View
public List<PasswordHistoryView> PasswordHistory { get; set; } public List<PasswordHistoryView> PasswordHistory { get; set; }
public HashSet<string> CollectionIds { get; set; } public HashSet<string> CollectionIds { get; set; }
public DateTime RevisionDate { get; set; } public DateTime RevisionDate { get; set; }
public DateTime? DeletedDate { get; set; }
public string SubTitle public string SubTitle
@ -96,5 +98,6 @@ namespace Bit.Core.Models.View
return Login.PasswordRevisionDate; return Login.PasswordRevisionDate;
} }
} }
public bool IsDeleted => DeletedDate.HasValue;
} }
} }

View File

@ -245,6 +245,16 @@ namespace Bit.Core.Services
return SendAsync<object, object>(HttpMethod.Delete, string.Concat("/ciphers/", id), null, true, false); return SendAsync<object, object>(HttpMethod.Delete, string.Concat("/ciphers/", id), null, true, false);
} }
public Task PutDeleteCipherAsync(string id)
{
return SendAsync<object, object>(HttpMethod.Put, string.Concat("/ciphers/", id, "/delete"), null, true, false);
}
public Task PutRestoreCipherAsync(string id)
{
return SendAsync<object, object>(HttpMethod.Put, string.Concat("/ciphers/", id, "/restore"), null, true, false);
}
#endregion #endregion
#region Attachments APIs #region Attachments APIs

View File

@ -265,6 +265,10 @@ namespace Bit.Core.Services
var ciphers = await GetAllDecryptedAsync(); var ciphers = await GetAllDecryptedAsync();
return ciphers.Where(cipher => return ciphers.Where(cipher =>
{ {
if (cipher.IsDeleted)
{
return false;
}
if (folder && cipher.FolderId == groupingId) if (folder && cipher.FolderId == groupingId)
{ {
return true; return true;
@ -324,6 +328,11 @@ namespace Bit.Core.Services
foreach (var cipher in ciphers) foreach (var cipher in ciphers)
{ {
if (cipher.IsDeleted)
{
continue;
}
if (cipher.Type != CipherType.Login && (includeOtherTypes?.Any(t => t == cipher.Type) ?? false)) if (cipher.Type != CipherType.Login && (includeOtherTypes?.Any(t => t == cipher.Type) ?? false))
{ {
others.Add(cipher); others.Add(cipher);
@ -695,6 +704,45 @@ namespace Bit.Core.Services
return null; 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<Dictionary<string, CipherData>>(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<Dictionary<string, CipherData>>(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 // Helpers
private async Task ShareAttachmentWithServerAsync(AttachmentView attachmentView, string cipherId, private async Task ShareAttachmentWithServerAsync(AttachmentView attachmentView, string cipherId,

View File

@ -35,7 +35,7 @@ namespace Bit.Core.Services
} }
public async Task<List<CipherView>> SearchCiphersAsync(string query, Func<CipherView, bool> filter = null, public async Task<List<CipherView>> SearchCiphersAsync(string query, Func<CipherView, bool> filter = null,
List<CipherView> ciphers = null, CancellationToken ct = default(CancellationToken)) List<CipherView> ciphers = null, CancellationToken ct = default)
{ {
var results = new List<CipherView>(); var results = new List<CipherView>();
if (query != null) if (query != null)
@ -68,7 +68,7 @@ namespace Bit.Core.Services
} }
public List<CipherView> SearchCiphersBasic(List<CipherView> ciphers, string query, public List<CipherView> SearchCiphersBasic(List<CipherView> ciphers, string query,
CancellationToken ct = default(CancellationToken)) CancellationToken ct = default, bool deleted = false)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
query = query.Trim().ToLower(); query = query.Trim().ToLower();

View File

@ -63,7 +63,7 @@ namespace Bit.iOS.Core.Views
} }
_allItems = combinedLogins _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)) .Select(s => new CipherViewModel(s))
.ToList() ?? new List<CipherViewModel>(); .ToList() ?? new List<CipherViewModel>();
FilterResults(searchFilter, new CancellationToken()); FilterResults(searchFilter, new CancellationToken());

View File

@ -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) if (_deviceActionService.SystemMajorVersion() >= 12)
{ {
@ -142,7 +142,7 @@ namespace Bit.iOS
await ASHelpers.ReplaceAllIdentities(); await ASHelpers.ReplaceAllIdentities();
} }
} }
else if (message.Command == "deletedCipher") else if (message.Command == "deletedCipher" || message.Command == "softDeletedCipher")
{ {
if (_deviceActionService.SystemMajorVersion() >= 12) if (_deviceActionService.SystemMajorVersion() >= 12)
{ {
@ -168,6 +168,11 @@ namespace Bit.iOS
await ASCredentialIdentityStore.SharedStore?.RemoveAllCredentialIdentitiesAsync(); await ASCredentialIdentityStore.SharedStore?.RemoveAllCredentialIdentitiesAsync();
} }
} }
else if ((message.Command == "softDeletedCipher" || message.Command == "restoredCipher")
&& _deviceActionService.SystemMajorVersion() >= 12)
{
await ASHelpers.ReplaceAllIdentities();
}
}); });
return base.FinishedLaunching(app, options); return base.FinishedLaunching(app, options);