diff --git a/src/App/App.csproj b/src/App/App.csproj
index b2e8ed94c..43411eb3f 100644
--- a/src/App/App.csproj
+++ b/src/App/App.csproj
@@ -192,6 +192,7 @@
+
diff --git a/src/App/Controls/SectionHeaderViewCell.cs b/src/App/Controls/SectionHeaderViewCell.cs
index ddae064b3..9353d7edb 100644
--- a/src/App/Controls/SectionHeaderViewCell.cs
+++ b/src/App/Controls/SectionHeaderViewCell.cs
@@ -18,7 +18,7 @@ namespace Bit.App.Controls
var stackLayout = new StackLayout
{
- Padding = padding ?? new Thickness(16, 8, 0, 8),
+ Padding = padding ?? new Thickness(16, 8),
Children = { label },
Orientation = StackOrientation.Horizontal
};
diff --git a/src/App/Models/Page/VaultListPageModel.cs b/src/App/Models/Page/VaultListPageModel.cs
index eb37ebac7..b9a6227da 100644
--- a/src/App/Models/Page/VaultListPageModel.cs
+++ b/src/App/Models/Page/VaultListPageModel.cs
@@ -21,6 +21,23 @@ namespace Bit.App.Models.Page
Name = cipher.Name?.Decrypt(cipher.OrganizationId);
Type = cipher.Type;
+ if(string.IsNullOrWhiteSpace(Name) || Name.Length == 0)
+ {
+ NameGroup = AppResources.Other;
+ }
+ else if(Char.IsLetter(Name[0]))
+ {
+ NameGroup = Name[0].ToString();
+ }
+ else if(Char.IsDigit(Name[0]))
+ {
+ NameGroup = "0 - 9";
+ }
+ else
+ {
+ NameGroup = AppResources.Other;
+ }
+
switch(cipher.Type)
{
case CipherType.Login:
@@ -120,6 +137,7 @@ namespace Bit.App.Models.Page
public bool Shared { get; set; }
public bool HasAttachments { get; set; }
public string FolderId { get; set; }
+ public string NameGroup { get; set; }
public string Name { get; set; }
public string Subtitle { get; set; }
public CipherType Type { get; set; }
@@ -165,6 +183,17 @@ namespace Bit.App.Models.Page
public string Name { get; set; } = AppResources.FolderNone;
}
+ public class NameGroup : List
+ {
+ public NameGroup(string nameGroup, List ciphers)
+ {
+ Name = nameGroup.ToUpperInvariant();
+ AddRange(ciphers);
+ }
+
+ public string Name { get; set; }
+ }
+
public class Section : List
{
public Section(List groupings, string name)
diff --git a/src/App/Pages/Vault/VaultListGroupingsPage.cs b/src/App/Pages/Vault/VaultListGroupingsPage.cs
index ad775c4ab..854cfbc85 100644
--- a/src/App/Pages/Vault/VaultListGroupingsPage.cs
+++ b/src/App/Pages/Vault/VaultListGroupingsPage.cs
@@ -55,15 +55,16 @@ namespace Bit.App.Pages
public ExtendedObservableCollection PresentationSections { get; private set; }
= new ExtendedObservableCollection();
public ListView ListView { get; set; }
- public SearchBar Search { get; set; }
public StackLayout NoDataStackLayout { get; set; }
- public StackLayout ResultsStackLayout { get; set; }
public ActivityIndicator LoadingIndicator { get; set; }
private AddCipherToolBarItem AddCipherItem { get; set; }
+ private SearchToolBarItem SearchItem { get; set; }
private void Init()
{
+ SearchItem = new SearchToolBarItem(this);
AddCipherItem = new AddCipherToolBarItem(this);
+ ToolbarItems.Add(SearchItem);
ToolbarItems.Add(AddCipherItem);
ListView = new ListView(ListViewCachingStrategy.RecycleElement)
@@ -82,26 +83,6 @@ namespace Bit.App.Pages
ListView.RowHeight = -1;
}
- Search = new SearchBar
- {
- Placeholder = AppResources.SearchVault,
- FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Button)),
- CancelButtonColor = Color.FromHex("3c8dbc")
- };
- // Bug with searchbar on android 7, ref https://bugzilla.xamarin.com/show_bug.cgi?id=43975
- if(Device.RuntimePlatform == Device.Android && _deviceInfoService.Version >= 24)
- {
- Search.HeightRequest = 50;
- }
-
- Title = AppResources.MyVault;
-
- ResultsStackLayout = new StackLayout
- {
- Children = { Search, ListView },
- Spacing = 0
- };
-
var noDataLabel = new Label
{
Text = AppResources.NoItems,
@@ -135,6 +116,7 @@ namespace Bit.App.Pages
};
Content = LoadingIndicator;
+ Title = AppResources.MyVault;
}
protected override void OnAppearing()
@@ -149,9 +131,8 @@ namespace Bit.App.Pages
});
ListView.ItemSelected += GroupingSelected;
- //Search.TextChanged += SearchBar_TextChanged;
- //Search.SearchButtonPressed += SearchBar_SearchButtonPressed;
AddCipherItem?.InitEvents();
+ SearchItem?.InitEvents();
_filterResultsCancellationTokenSource = FetchAndLoadVault();
}
@@ -162,21 +143,8 @@ namespace Bit.App.Pages
MessagingCenter.Unsubscribe(_syncService, "SyncCompleted");
ListView.ItemSelected -= GroupingSelected;
- //Search.TextChanged -= SearchBar_TextChanged;
- //Search.SearchButtonPressed -= SearchBar_SearchButtonPressed;
AddCipherItem?.Dispose();
- }
-
- private void AdjustContent()
- {
- if(PresentationSections.Count > 0 || !string.IsNullOrWhiteSpace(Search.Text))
- {
- Content = ResultsStackLayout;
- }
- else
- {
- Content = NoDataStackLayout;
- }
+ SearchItem?.Dispose();
}
private CancellationTokenSource FetchAndLoadVault()
@@ -213,10 +181,7 @@ namespace Bit.App.Pages
.Select(f => new VaultListPageModel.Grouping(f, folderCounts.ContainsKey(f.Id) ? folderCounts[f.Id] : 0))
.OrderBy(g => g.Name).ToList();
folderGroupings.Add(new VaultListPageModel.Grouping(AppResources.FolderNone, folderCounts["none"]));
- if(folderGroupings?.Any() ?? false)
- {
- sections.Add(new VaultListPageModel.Section(folderGroupings, AppResources.Folders));
- }
+ sections.Add(new VaultListPageModel.Section(folderGroupings, AppResources.Folders));
var collections = await _collectionService.GetAllAsync();
var collectionGroupings = collections?
@@ -235,7 +200,14 @@ namespace Bit.App.Pages
PresentationSections.ResetWithRange(sections);
}
- AdjustContent();
+ if(PresentationSections.Count > 0)
+ {
+ Content = ListView;
+ }
+ else
+ {
+ Content = NoDataStackLayout;
+ }
});
}, cts.Token);
@@ -280,17 +252,30 @@ namespace Bit.App.Pages
await Navigation.PushForDeviceAsync(page);
}
+ private async void Search()
+ {
+ var page = new ExtendedNavigationPage(new VaultSearchCiphersPage());
+ await Navigation.PushModalAsync(page);
+ }
+
private class AddCipherToolBarItem : ExtendedToolbarItem
{
- private readonly VaultListGroupingsPage _page;
-
public AddCipherToolBarItem(VaultListGroupingsPage page)
: base(() => page.AddCipher())
{
- _page = page;
Text = AppResources.Add;
Icon = "plus.png";
}
}
+
+ private class SearchToolBarItem : ExtendedToolbarItem
+ {
+ public SearchToolBarItem(VaultListGroupingsPage page)
+ : base(() => page.Search())
+ {
+ Text = AppResources.Search;
+ Icon = "search.png";
+ }
+ }
}
}
diff --git a/src/App/Pages/Vault/VaultSearchCiphersPage.cs b/src/App/Pages/Vault/VaultSearchCiphersPage.cs
new file mode 100644
index 000000000..0fd6ee591
--- /dev/null
+++ b/src/App/Pages/Vault/VaultSearchCiphersPage.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Acr.UserDialogs;
+using Bit.App.Abstractions;
+using Bit.App.Controls;
+using Bit.App.Models.Page;
+using Bit.App.Resources;
+using Xamarin.Forms;
+using XLabs.Ioc;
+using Bit.App.Utilities;
+using Plugin.Settings.Abstractions;
+using Plugin.Connectivity.Abstractions;
+using System.Collections.Generic;
+using System.Threading;
+using Bit.App.Enums;
+
+namespace Bit.App.Pages
+{
+ public class VaultSearchCiphersPage : ExtendedContentPage
+ {
+ private readonly IFolderService _folderService;
+ private readonly ICipherService _cipherService;
+ private readonly IUserDialogs _userDialogs;
+ private readonly IConnectivity _connectivity;
+ private readonly IDeviceActionService _deviceActionService;
+ private readonly ISyncService _syncService;
+ private readonly IPushNotificationService _pushNotification;
+ private readonly IDeviceInfoService _deviceInfoService;
+ private readonly ISettings _settings;
+ private readonly IAppSettingsService _appSettingsService;
+ private readonly IGoogleAnalyticsService _googleAnalyticsService;
+ private CancellationTokenSource _filterResultsCancellationTokenSource;
+
+ public VaultSearchCiphersPage()
+ : base(true)
+ {
+ _folderService = Resolver.Resolve();
+ _cipherService = Resolver.Resolve();
+ _connectivity = Resolver.Resolve();
+ _userDialogs = Resolver.Resolve();
+ _deviceActionService = Resolver.Resolve();
+ _syncService = Resolver.Resolve();
+ _pushNotification = Resolver.Resolve();
+ _deviceInfoService = Resolver.Resolve();
+ _settings = Resolver.Resolve();
+ _appSettingsService = Resolver.Resolve();
+ _googleAnalyticsService = Resolver.Resolve();
+
+ Init();
+ }
+
+ public ExtendedObservableCollection PresentationLetters { get; private set; }
+ = new ExtendedObservableCollection();
+ public VaultListPageModel.Cipher[] Ciphers { get; set; } = new VaultListPageModel.Cipher[] { };
+ public ListView ListView { get; set; }
+ public SearchBar Search { get; set; }
+ public StackLayout ResultsStackLayout { get; set; }
+
+ private void Init()
+ {
+ ListView = new ListView(ListViewCachingStrategy.RecycleElement)
+ {
+ IsGroupingEnabled = true,
+ ItemsSource = PresentationLetters,
+ HasUnevenRows = true,
+ GroupHeaderTemplate = new DataTemplate(() => new SectionHeaderViewCell(
+ nameof(VaultListPageModel.NameGroup.Name), nameof(VaultListPageModel.NameGroup.Count))),
+ ItemTemplate = new DataTemplate(() => new VaultListViewCell(
+ (VaultListPageModel.Cipher c) => MoreClickedAsync(c)))
+ };
+
+ if(Device.RuntimePlatform == Device.iOS)
+ {
+ ListView.RowHeight = -1;
+ }
+
+ Search = new SearchBar
+ {
+ Placeholder = AppResources.SearchVault,
+ FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Button)),
+ CancelButtonColor = Color.FromHex("3c8dbc")
+ };
+ // Bug with searchbar on android 7, ref https://bugzilla.xamarin.com/show_bug.cgi?id=43975
+ if(Device.RuntimePlatform == Device.Android && _deviceInfoService.Version >= 24)
+ {
+ Search.HeightRequest = 50;
+ }
+
+ ResultsStackLayout = new StackLayout
+ {
+ Children = { Search, ListView },
+ Spacing = 0
+ };
+
+ Title = AppResources.SearchVault;
+ Content = new ActivityIndicator
+ {
+ IsRunning = true,
+ VerticalOptions = LayoutOptions.CenterAndExpand,
+ HorizontalOptions = LayoutOptions.Center
+ };
+ }
+
+ private void SearchBar_SearchButtonPressed(object sender, EventArgs e)
+ {
+ _filterResultsCancellationTokenSource = FilterResultsBackground(((SearchBar)sender).Text,
+ _filterResultsCancellationTokenSource);
+ }
+
+ private void SearchBar_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ var oldLength = e.OldTextValue?.Length ?? 0;
+ var newLength = e.NewTextValue?.Length ?? 0;
+ if(oldLength < 2 && newLength < 2 && oldLength < newLength)
+ {
+ return;
+ }
+
+ _filterResultsCancellationTokenSource = FilterResultsBackground(e.NewTextValue,
+ _filterResultsCancellationTokenSource);
+ }
+
+ private CancellationTokenSource FilterResultsBackground(string searchFilter,
+ CancellationTokenSource previousCts)
+ {
+ var cts = new CancellationTokenSource();
+ Task.Run(async () =>
+ {
+ if(!string.IsNullOrWhiteSpace(searchFilter))
+ {
+ await Task.Delay(300);
+ if(searchFilter != Search.Text)
+ {
+ return;
+ }
+ else
+ {
+ previousCts?.Cancel();
+ }
+ }
+
+ try
+ {
+ FilterResults(searchFilter, cts.Token);
+ }
+ catch(OperationCanceledException) { }
+ }, cts.Token);
+
+ return cts;
+ }
+
+ private void FilterResults(string searchFilter, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if(string.IsNullOrWhiteSpace(searchFilter))
+ {
+ LoadLetters(Ciphers, ct);
+ }
+ else
+ {
+ searchFilter = searchFilter.ToLower();
+ var filteredCiphers = Ciphers
+ .Where(s => s.Name.ToLower().Contains(searchFilter) ||
+ (s.Subtitle?.ToLower().Contains(searchFilter) ?? false))
+ .TakeWhile(s => !ct.IsCancellationRequested)
+ .ToArray();
+
+ ct.ThrowIfCancellationRequested();
+ LoadLetters(filteredCiphers, ct);
+ }
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ MessagingCenter.Subscribe(_syncService, "SyncCompleted", (sender, success) =>
+ {
+ if(success)
+ {
+ _filterResultsCancellationTokenSource = FetchAndLoadVault();
+ }
+ });
+
+ ListView.ItemSelected += CipherSelected;
+ Search.TextChanged += SearchBar_TextChanged;
+ Search.SearchButtonPressed += SearchBar_SearchButtonPressed;
+
+ _filterResultsCancellationTokenSource = FetchAndLoadVault();
+ }
+
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+ MessagingCenter.Unsubscribe(_syncService, "SyncCompleted");
+
+ ListView.ItemSelected -= CipherSelected;
+ Search.TextChanged -= SearchBar_TextChanged;
+ Search.SearchButtonPressed -= SearchBar_SearchButtonPressed;
+ }
+
+ private CancellationTokenSource FetchAndLoadVault()
+ {
+ var cts = new CancellationTokenSource();
+ if(PresentationLetters.Count > 0 && _syncService.SyncInProgress)
+ {
+ return cts;
+ }
+
+ _filterResultsCancellationTokenSource?.Cancel();
+
+ Task.Run(async () =>
+ {
+ var ciphers = await _cipherService.GetAllAsync();
+
+ Ciphers = ciphers
+ .Select(s => new VaultListPageModel.Cipher(s, _appSettingsService))
+ .OrderBy(s =>
+ {
+ // Sort numbers and letters before special characters
+ return !string.IsNullOrWhiteSpace(s.Name) && s.Name.Length > 0 &&
+ Char.IsLetterOrDigit(s.Name[0]) ? 0 : 1;
+ })
+ .ThenBy(s => s.Name)
+ .ThenBy(s => s.Subtitle)
+ .ToArray();
+
+ try
+ {
+ FilterResults(Search.Text, cts.Token);
+ }
+ catch(OperationCanceledException) { }
+ }, cts.Token);
+
+ return cts;
+ }
+
+ private void LoadLetters(VaultListPageModel.Cipher[] ciphers, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var letterGroups = ciphers.GroupBy(c => c.NameGroup)
+ .Select(g => new VaultListPageModel.NameGroup(g.Key, g.ToList()));
+ ct.ThrowIfCancellationRequested();
+ Device.BeginInvokeOnMainThread(() =>
+ {
+ PresentationLetters.ResetWithRange(letterGroups);
+ Content = ResultsStackLayout;
+ });
+ }
+
+ private async void CipherSelected(object sender, SelectedItemChangedEventArgs e)
+ {
+ var cipher = e.SelectedItem as VaultListPageModel.Cipher;
+ if(cipher == null)
+ {
+ return;
+ }
+
+ var page = new VaultViewCipherPage(cipher.Type, cipher.Id);
+ await Navigation.PushForDeviceAsync(page);
+ ((ListView)sender).SelectedItem = null;
+ }
+
+ private async void MoreClickedAsync(VaultListPageModel.Cipher cipher)
+ {
+ var buttons = new List { AppResources.View, AppResources.Edit };
+
+ if(cipher.Type == CipherType.Login)
+ {
+ if(!string.IsNullOrWhiteSpace(cipher.LoginPassword.Value))
+ {
+ buttons.Add(AppResources.CopyPassword);
+ }
+ if(!string.IsNullOrWhiteSpace(cipher.LoginUsername))
+ {
+ buttons.Add(AppResources.CopyUsername);
+ }
+ if(!string.IsNullOrWhiteSpace(cipher.LoginUri) && (cipher.LoginUri.StartsWith("http://")
+ || cipher.LoginUri.StartsWith("https://")))
+ {
+ buttons.Add(AppResources.GoToWebsite);
+ }
+ }
+ else if(cipher.Type == CipherType.Card)
+ {
+ if(!string.IsNullOrWhiteSpace(cipher.CardNumber))
+ {
+ buttons.Add(AppResources.CopyNumber);
+ }
+ if(!string.IsNullOrWhiteSpace(cipher.CardCode.Value))
+ {
+ buttons.Add(AppResources.CopySecurityCode);
+ }
+ }
+
+ var selection = await DisplayActionSheet(cipher.Name, AppResources.Cancel, null, buttons.ToArray());
+
+ if(selection == AppResources.View)
+ {
+ var page = new VaultViewCipherPage(cipher.Type, cipher.Id);
+ await Navigation.PushForDeviceAsync(page);
+ }
+ else if(selection == AppResources.Edit)
+ {
+ var page = new VaultEditCipherPage(cipher.Id);
+ await Navigation.PushForDeviceAsync(page);
+ }
+ else if(selection == AppResources.CopyPassword)
+ {
+ Copy(cipher.LoginPassword.Value, AppResources.Password);
+ }
+ else if(selection == AppResources.CopyUsername)
+ {
+ Copy(cipher.LoginUsername, AppResources.Username);
+ }
+ else if(selection == AppResources.GoToWebsite)
+ {
+ Device.OpenUri(new Uri(cipher.LoginUri));
+ }
+ else if(selection == AppResources.CopyNumber)
+ {
+ Copy(cipher.CardNumber, AppResources.Number);
+ }
+ else if(selection == AppResources.CopySecurityCode)
+ {
+ Copy(cipher.CardCode.Value, AppResources.SecurityCode);
+ }
+ }
+
+ private void Copy(string copyText, string alertLabel)
+ {
+ _deviceActionService.CopyToClipboard(copyText);
+ _userDialogs.Toast(string.Format(AppResources.ValueHasBeenCopied, alertLabel));
+ }
+ }
+}