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)); + } + } +}