diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index 2f7df60bd..cb50c5a5c 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -123,7 +123,9 @@ + + @@ -131,6 +133,7 @@ + @@ -176,6 +179,7 @@ + @@ -183,6 +187,7 @@ + diff --git a/src/Android/Renderers/ExtendedDatePickerRenderer.cs b/src/Android/Renderers/ExtendedDatePickerRenderer.cs new file mode 100644 index 000000000..263171b3b --- /dev/null +++ b/src/Android/Renderers/ExtendedDatePickerRenderer.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using Android.Content; +using Android.Views; +using Bit.App.Controls; +using Bit.Droid.Renderers; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; + +[assembly: ExportRenderer(typeof(ExtendedDatePicker), typeof(ExtendedDatePickerRenderer))] +namespace Bit.Droid.Renderers +{ + public class ExtendedDatePickerRenderer : DatePickerRenderer + { + public ExtendedDatePickerRenderer(Context context) + : base(context) { } + + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + if (Control != null && Element is ExtendedDatePicker element) + { + // center text + Control.Gravity = GravityFlags.CenterHorizontal; + + // use placeholder until NullableDate set + if (!element.NullableDate.HasValue) + { + Control.Text = element.PlaceHolder; + } + } + } + + protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == DatePicker.DateProperty.PropertyName || + e.PropertyName == DatePicker.FormatProperty.PropertyName) + { + if (Control != null && Element is ExtendedDatePicker element) + { + if (Element.Format == element.PlaceHolder) + { + Control.Text = element.PlaceHolder; + return; + } + } + } + base.OnElementPropertyChanged(sender, e); + } + } +} diff --git a/src/Android/Renderers/ExtendedTimePickerRenderer.cs b/src/Android/Renderers/ExtendedTimePickerRenderer.cs new file mode 100644 index 000000000..5b38a3034 --- /dev/null +++ b/src/Android/Renderers/ExtendedTimePickerRenderer.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using Android.Content; +using Android.Views; +using Bit.App.Controls; +using Bit.Droid.Renderers; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; + +[assembly: ExportRenderer(typeof(ExtendedTimePicker), typeof(ExtendedTimePickerRenderer))] +namespace Bit.Droid.Renderers +{ + public class ExtendedTimePickerRenderer : TimePickerRenderer + { + public ExtendedTimePickerRenderer(Context context) + : base(context) { } + + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + if (Control != null && Element is ExtendedTimePicker element) + { + // center text + Control.Gravity = GravityFlags.CenterHorizontal; + + // use placeholder until NullableTime set + if (!element.NullableTime.HasValue) + { + Control.Text = element.PlaceHolder; + } + } + } + + protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == TimePicker.TimeProperty.PropertyName || + e.PropertyName == TimePicker.FormatProperty.PropertyName) + { + if (Control != null && Element is ExtendedTimePicker element) + { + if (Element.Format == element.PlaceHolder) + { + Control.Text = element.PlaceHolder; + return; + } + } + } + base.OnElementPropertyChanged(sender, e); + } + } +} diff --git a/src/Android/Renderers/SendViewCellRenderer.cs b/src/Android/Renderers/SendViewCellRenderer.cs new file mode 100644 index 000000000..4842c37af --- /dev/null +++ b/src/Android/Renderers/SendViewCellRenderer.cs @@ -0,0 +1,199 @@ +using System; +using System.ComponentModel; +using Android.App; +using Android.Content; +using Android.Graphics; +using Android.Util; +using Android.Views; +using Android.Widget; +using Bit.App.Controls; +using Bit.App.Utilities; +using Bit.Droid.Renderers; +using FFImageLoading.Work; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; +using Button = Android.Widget.Button; +using Color = Android.Graphics.Color; +using View = Android.Views.View; + +[assembly: ExportRenderer(typeof(SendViewCell), typeof(SendViewCellRenderer))] +namespace Bit.Droid.Renderers +{ + public class SendViewCellRenderer : ViewCellRenderer + { + private static Typeface _faTypeface; + private static Typeface _miTypeface; + private static Color _textColor; + private static Color _mutedColor; + private static Color _disabledIconColor; + private static bool _usingLightTheme; + + private AndroidSendCell _cell; + + protected override View GetCellCore(Cell item, View convertView, + ViewGroup parent, Context context) + { + // TODO expand beyond light/dark detection once we support custom theme switching without app restart + var themeChanged = _usingLightTheme != ThemeManager.UsingLightTheme; + if (_faTypeface == null) + { + _faTypeface = Typeface.CreateFromAsset(context.Assets, "FontAwesome.ttf"); + } + if (_miTypeface == null) + { + _miTypeface = Typeface.CreateFromAsset(context.Assets, "MaterialIcons_Regular.ttf"); + } + if (_textColor == default(Color) || themeChanged) + { + _textColor = ThemeManager.GetResourceColor("TextColor").ToAndroid(); + } + if (_mutedColor == default(Color) || themeChanged) + { + _mutedColor = ThemeManager.GetResourceColor("MutedColor").ToAndroid(); + } + if (_disabledIconColor == default(Color) || themeChanged) + { + _disabledIconColor = ThemeManager.GetResourceColor("DisabledIconColor").ToAndroid(); + } + _usingLightTheme = ThemeManager.UsingLightTheme; + + var sendCell = item as SendViewCell; + _cell = convertView as AndroidSendCell; + if (_cell == null) + { + _cell = new AndroidSendCell(context, sendCell, _faTypeface, _miTypeface); + } + else + { + _cell.SendViewCell.PropertyChanged -= CellPropertyChanged; + } + sendCell.PropertyChanged += CellPropertyChanged; + _cell.UpdateCell(sendCell); + _cell.UpdateColors(_textColor, _mutedColor, _disabledIconColor); + return _cell; + } + + public void CellPropertyChanged(object sender, PropertyChangedEventArgs e) + { + var sendCell = sender as SendViewCell; + _cell.SendViewCell = sendCell; + if (e.PropertyName == SendViewCell.SendProperty.PropertyName) + { + _cell.UpdateCell(sendCell); + } + } + } + + public class AndroidSendCell : LinearLayout, INativeElementView + { + private readonly Typeface _faTypeface; + private readonly Typeface _miTypeface; + + private IScheduledWork _currentTask; + + public AndroidSendCell(Context context, SendViewCell sendView, Typeface faTypeface, Typeface miTypeface) + : base(context) + { + SendViewCell = sendView; + _faTypeface = faTypeface; + _miTypeface = miTypeface; + + var view = (context as Activity).LayoutInflater.Inflate(Resource.Layout.SendViewCell, null); + Icon = view.FindViewById(Resource.Id.SendCellIcon); + Name = view.FindViewById(Resource.Id.SendCellName); + SubTitle = view.FindViewById(Resource.Id.SendCellSubTitle); + HasPasswordIcon = view.FindViewById(Resource.Id.SendCellHasPasswordIcon); + MaxAccessCountReachedIcon = view.FindViewById(Resource.Id.SendCellMaxAccessCountReachedIcon); + ExpiredIcon = view.FindViewById(Resource.Id.SendCellExpiredIcon); + PendingDeleteIcon = view.FindViewById(Resource.Id.SendCellPendingDeleteIcon); + MoreButton = view.FindViewById + + + \ No newline at end of file diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPage.xaml.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPage.xaml.cs new file mode 100644 index 000000000..6e128dfc2 --- /dev/null +++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPage.xaml.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bit.App.Controls; +using Bit.App.Models; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class SendGroupingsPage : BaseContentPage + { + private readonly IBroadcasterService _broadcasterService; + private readonly ISyncService _syncService; + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly ISendService _sendService; + private readonly SendGroupingsPageViewModel _vm; + private readonly string _pageName; + + private PreviousPageInfo _previousPage; + + public SendGroupingsPage(bool mainPage, SendType? type = null, string pageTitle = null, + PreviousPageInfo previousPage = null) + { + _pageName = string.Concat(nameof(GroupingsPage), "_", DateTime.UtcNow.Ticks); + InitializeComponent(); + ListView = _listView; + _broadcasterService = ServiceContainer.Resolve("broadcasterService"); + _syncService = ServiceContainer.Resolve("syncService"); + _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); + _sendService = ServiceContainer.Resolve("sendService"); + _vm = BindingContext as SendGroupingsPageViewModel; + _vm.Page = this; + _vm.MainPage = mainPage; + _vm.Type = type; + _previousPage = previousPage; + if (pageTitle != null) + { + _vm.PageTitle = pageTitle; + } + + if (Device.RuntimePlatform == Device.iOS) + { + _absLayout.Children.Remove(_fab); + ToolbarItems.Add(_addItem); + } + else + { + ToolbarItems.Add(_syncItem); + ToolbarItems.Add(_lockItem); + } + } + + public ExtendedListView ListView { get; set; } + + protected async override void OnAppearing() + { + base.OnAppearing(); + if (_syncService.SyncInProgress) + { + IsBusy = true; + } + + _broadcasterService.Subscribe(_pageName, async (message) => + { + if (message.Command == "syncStarted") + { + Device.BeginInvokeOnMainThread(() => IsBusy = true); + } + else if (message.Command == "syncCompleted") + { + await Task.Delay(500); + Device.BeginInvokeOnMainThread(() => + { + IsBusy = false; + if (_vm.LoadedOnce) + { + var task = _vm.LoadAsync(); + } + }); + } + }); + + await LoadOnAppearedAsync(_mainLayout, false, async () => + { + if (!_syncService.SyncInProgress || (await _sendService.GetAllAsync()).Any()) + { + try + { + await _vm.LoadAsync(); + } + catch (Exception e) when (e.Message.Contains("No key.")) + { + await Task.Delay(1000); + await _vm.LoadAsync(); + } + } + else + { + await Task.Delay(5000); + if (!_vm.Loaded) + { + await _vm.LoadAsync(); + } + } + + await ShowPreviousPageAsync(); + }, _mainContent); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + IsBusy = false; + _broadcasterService.Unsubscribe(_pageName); + _vm.DisableRefreshing(); + } + + private async void RowSelected(object sender, SelectedItemChangedEventArgs e) + { + ((ListView)sender).SelectedItem = null; + if (!DoOnce()) + { + return; + } + if (!(e.SelectedItem is SendGroupingsPageListItem item)) + { + return; + } + + if (item.Send != null) + { + await _vm.SelectSendAsync(item.Send); + } + else if (item.Type != null) + { + await _vm.SelectTypeAsync(item.Type.Value); + } + } + + private async void Search_Clicked(object sender, EventArgs e) + { + if (DoOnce()) + { + var page = new SendsPage(_vm.Filter, _vm.Type != null); + await Navigation.PushModalAsync(new NavigationPage(page), false); + } + } + + private async void Sync_Clicked(object sender, EventArgs e) + { + await _vm.SyncAsync(); + } + + private async void Lock_Clicked(object sender, EventArgs e) + { + await _vaultTimeoutService.LockAsync(true, true); + } + + private async void AddButton_Clicked(object sender, EventArgs e) + { + if (DoOnce()) + { + var page = new SendAddEditPage(null, _vm.Type); + await Navigation.PushModalAsync(new NavigationPage(page)); + } + } + + private async Task ShowPreviousPageAsync() + { + if (_previousPage == null) + { + return; + } + if (_previousPage.Page == "view" && !string.IsNullOrWhiteSpace(_previousPage.SendId)) + { + await Navigation.PushModalAsync(new NavigationPage(new ViewPage(_previousPage.SendId))); + } + else if (_previousPage.Page == "edit" && !string.IsNullOrWhiteSpace(_previousPage.SendId)) + { + await Navigation.PushModalAsync(new NavigationPage(new AddEditPage(_previousPage.SendId))); + } + _previousPage = null; + } + } +} diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListGroup.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListGroup.cs new file mode 100644 index 000000000..2724c7fc2 --- /dev/null +++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListGroup.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Bit.App.Pages +{ + public class SendGroupingsPageListGroup : List + { + public SendGroupingsPageListGroup(string name, int count, bool doUpper = true, bool first = false) + : this(new List(), name, count, doUpper, first) { } + + public SendGroupingsPageListGroup(List groupItems, string name, int count, + bool doUpper = true, bool first = false) + { + AddRange(groupItems); + if (string.IsNullOrWhiteSpace(name)) + { + Name = "-"; + } + else if (doUpper) + { + Name = name.ToUpperInvariant(); + } + else + { + Name = name; + } + ItemCount = count > 0 ? count.ToString("N0") : ""; + First = first; + } + + public bool First { get; set; } + public string Name { get; set; } + public string NameShort => string.IsNullOrWhiteSpace(Name) || Name.Length == 0 ? "-" : Name[0].ToString(); + public string ItemCount { get; set; } + } +} diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItem.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItem.cs new file mode 100644 index 000000000..bbc6522b3 --- /dev/null +++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItem.cs @@ -0,0 +1,68 @@ +using Bit.App.Resources; +using Bit.Core.Enums; +using Bit.Core.Models.View; + +namespace Bit.App.Pages +{ + public class SendGroupingsPageListItem + { + private string _icon; + private string _name; + + public SendView Send { get; set; } + public SendType? Type { get; set; } + public string ItemCount { get; set; } + + public string Name + { + get + { + if (_name != null) + { + return _name; + } + if (Type != null) + { + switch (Type.Value) + { + case SendType.Text: + _name = AppResources.TypeText; + break; + case SendType.File: + _name = AppResources.TypeFile; + break; + default: + break; + } + } + return _name; + } + } + + public string Icon + { + get + { + if (_icon != null) + { + return _icon; + } + if (Type != null) + { + switch (Type.Value) + { + case SendType.Text: + _icon = "\uf0f6"; // fa-file-text-o + break; + case SendType.File: + _icon = "\uf016"; // fa-file-o + break; + default: + break; + } + } + return _icon; + } + } + } +} diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItemSelector.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItemSelector.cs new file mode 100644 index 000000000..885bc0fb5 --- /dev/null +++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageListItemSelector.cs @@ -0,0 +1,19 @@ +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class SendGroupingsPageListItemSelector : DataTemplateSelector + { + public DataTemplate SendTemplate { get; set; } + public DataTemplate GroupTemplate { get; set; } + + protected override DataTemplate OnSelectTemplate(object item, BindableObject container) + { + if (item is SendGroupingsPageListItem listItem) + { + return listItem.Send != null ? SendTemplate : GroupTemplate; + } + return null; + } + } +} diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs new file mode 100644 index 000000000..824c2b5fb --- /dev/null +++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bit.App.Abstractions; +using Bit.App.Resources; +using Bit.App.Utilities; +using Bit.Core; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Models.View; +using Bit.Core.Utilities; +using Xamarin.Essentials; +using Xamarin.Forms; +using DeviceType = Bit.Core.Enums.DeviceType; + +namespace Bit.App.Pages +{ + public class SendGroupingsPageViewModel : BaseViewModel + { + private bool _refreshing; + private bool _doingLoad; + private bool _loading; + private bool _loaded; + private bool _showAddSendButton; + private bool _showNoData; + private bool _showList; + private bool _syncRefreshing; + private string _noDataText; + private List _allSends; + private Dictionary _typeCounts = new Dictionary(); + + private readonly ISendService _sendService; + private readonly ISyncService _syncService; + private readonly IUserService _userService; + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly IDeviceActionService _deviceActionService; + private readonly IPlatformUtilsService _platformUtilsService; + private readonly IStorageService _storageService; + + public SendGroupingsPageViewModel() + { + _sendService = ServiceContainer.Resolve("sendService"); + _syncService = ServiceContainer.Resolve("syncService"); + _userService = ServiceContainer.Resolve("userService"); + _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); + _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + _storageService = ServiceContainer.Resolve("storageService"); + + Loading = true; + PageTitle = AppResources.Send; + GroupedSends = new ExtendedObservableCollection(); + RefreshCommand = new Command(async () => + { + Refreshing = true; + await LoadAsync(); + }); + SendOptionsCommand = new Command(SendOptionsAsync); + } + + public bool MainPage { get; set; } + public SendType? Type { get; set; } + public Func Filter { get; set; } + public bool HasSends { get; set; } + public List Sends { get; set; } + + public bool Refreshing + { + get => _refreshing; + set => SetProperty(ref _refreshing, value); + } + public bool SyncRefreshing + { + get => _syncRefreshing; + set => SetProperty(ref _syncRefreshing, value); + } + public bool Loading + { + get => _loading; + set => SetProperty(ref _loading, value); + } + public bool Loaded + { + get => _loaded; + set => SetProperty(ref _loaded, value); + } + public bool ShowAddSendButton + { + get => _showAddSendButton; + set => SetProperty(ref _showAddSendButton, value); + } + public bool ShowNoData + { + get => _showNoData; + set => SetProperty(ref _showNoData, value); + } + public string NoDataText + { + get => _noDataText; + set => SetProperty(ref _noDataText, value); + } + public bool ShowList + { + get => _showList; + set => SetProperty(ref _showList, value); + } + public ExtendedObservableCollection GroupedSends { get; set; } + public Command RefreshCommand { get; set; } + public Command SendOptionsCommand { get; set; } + public bool LoadedOnce { get; set; } + + public async Task LoadAsync() + { + if (_doingLoad) + { + return; + } + var authed = await _userService.IsAuthenticatedAsync(); + if (!authed) + { + return; + } + if (await _vaultTimeoutService.IsLockedAsync()) + { + return; + } + if (await _storageService.GetAsync(Constants.SyncOnRefreshKey) && Refreshing && !SyncRefreshing) + { + SyncRefreshing = true; + await _syncService.FullSyncAsync(false); + return; + } + + _doingLoad = true; + LoadedOnce = true; + ShowNoData = false; + Loading = true; + ShowList = false; + var groupedSends = new List(); + var page = Page as SendGroupingsPage; + + try + { + await LoadDataAsync(); + + var uppercaseGroupNames = _deviceActionService.DeviceType == DeviceType.iOS; + if (MainPage) + { + groupedSends.Add(new SendGroupingsPageListGroup( + AppResources.Types, 0, uppercaseGroupNames, true) + { + new SendGroupingsPageListItem + { + Type = SendType.Text, + ItemCount = (_typeCounts.ContainsKey(SendType.Text) ? + _typeCounts[SendType.Text] : 0).ToString("N0") + }, + new SendGroupingsPageListItem + { + Type = SendType.File, + ItemCount = (_typeCounts.ContainsKey(SendType.File) ? + _typeCounts[SendType.File] : 0).ToString("N0") + }, + }); + } + + if (Sends?.Any() ?? false) + { + var sendsListItems = Sends.Select(s => new SendGroupingsPageListItem { Send = s }).ToList(); + groupedSends.Add(new SendGroupingsPageListGroup(sendsListItems, + MainPage ? AppResources.AllSends : AppResources.Sends, sendsListItems.Count, + uppercaseGroupNames, !MainPage)); + } + GroupedSends.ResetWithRange(groupedSends); + } + finally + { + _doingLoad = false; + Loaded = true; + Loading = false; + ShowNoData = (MainPage && !HasSends) || !groupedSends.Any(); + ShowList = !ShowNoData; + DisableRefreshing(); + } + } + + public void DisableRefreshing() + { + Refreshing = false; + SyncRefreshing = false; + } + + public async Task SelectSendAsync(SendView send) + { + var page = new SendAddEditPage(send.Id); + await Page.Navigation.PushModalAsync(new NavigationPage(page)); + } + + public async Task SelectTypeAsync(SendType type) + { + string title = null; + switch (type) + { + case SendType.Text: + title = AppResources.TypeText; + break; + case SendType.File: + title = AppResources.TypeFile; + break; + default: + break; + } + var page = new SendGroupingsPage(false, type, title); + await Page.Navigation.PushAsync(page); + } + + public async Task SyncAsync() + { + if (Connectivity.NetworkAccess == NetworkAccess.None) + { + await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, + AppResources.InternetConnectionRequiredTitle); + return; + } + await _deviceActionService.ShowLoadingAsync(AppResources.Syncing); + try + { + await _syncService.FullSyncAsync(false, true); + await _deviceActionService.HideLoadingAsync(); + _platformUtilsService.ShowToast("success", null, AppResources.SyncingComplete); + } + catch + { + await _deviceActionService.HideLoadingAsync(); + _platformUtilsService.ShowToast("error", null, AppResources.SyncingFailed); + } + } + + private async Task LoadDataAsync() + { + NoDataText = AppResources.NoSends; + _allSends = await _sendService.GetAllDecryptedAsync(); + HasSends = _allSends.Any(); + _typeCounts.Clear(); + Filter = null; + + if (MainPage) + { + Sends = _allSends; + foreach (var c in _allSends) + { + if (_typeCounts.ContainsKey(c.Type)) + { + _typeCounts[c.Type] = _typeCounts[c.Type] + 1; + } + else + { + _typeCounts.Add(c.Type, 1); + } + } + } + else + { + if (Type != null) + { + Filter = c => c.Type == Type.Value; + } + else + { + PageTitle = AppResources.AllSends; + } + Sends = Filter != null ? _allSends.Where(Filter).ToList() : _allSends; + } + } + + private async void SendOptionsAsync(SendView send) + { + if ((Page as BaseContentPage).DoOnce()) + { + var selection = await AppHelpers.SendListOptions(Page, send); + if (selection == AppResources.RemovePassword || selection == AppResources.Delete) + { + await LoadAsync(); + } + } + } + } +} diff --git a/src/App/Pages/Send/SendsPage.xaml b/src/App/Pages/Send/SendsPage.xaml new file mode 100644 index 000000000..a3badcdb3 --- /dev/null +++ b/src/App/Pages/Send/SendsPage.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App/Pages/Send/SendsPage.xaml.cs b/src/App/Pages/Send/SendsPage.xaml.cs new file mode 100644 index 000000000..09937ca6f --- /dev/null +++ b/src/App/Pages/Send/SendsPage.xaml.cs @@ -0,0 +1,109 @@ +using System; +using Bit.App.Resources; +using Bit.Core.Models.View; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class SendsPage : BaseContentPage + { + private SendsPageViewModel _vm; + private bool _hasFocused; + + public SendsPage(Func filter, bool type = false) + { + InitializeComponent(); + _vm = BindingContext as SendsPageViewModel; + _vm.Page = this; + _vm.Filter = filter; + if (type) + { + _vm.PageTitle = AppResources.SearchType; + } + else + { + _vm.PageTitle = AppResources.SearchSends; + } + + if (Device.RuntimePlatform == Device.iOS) + { + ToolbarItems.Add(_closeItem); + _searchBar.Placeholder = AppResources.Search; + _mainLayout.Children.Insert(0, _searchBar); + _mainLayout.Children.Insert(1, _separator); + } + else + { + NavigationPage.SetTitleView(this, _titleLayout); + } + } + + public SearchBar SearchBar => _searchBar; + + protected async override void OnAppearing() + { + base.OnAppearing(); + await _vm.InitAsync(); + if (!_hasFocused) + { + _hasFocused = true; + RequestFocus(_searchBar); + } + } + + 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; + } + _vm.Search(e.NewTextValue, 200); + } + + private void SearchBar_SearchButtonPressed(object sender, EventArgs e) + { + _vm.Search((sender as SearchBar).Text); + } + + private void BackButton_Clicked(object sender, EventArgs e) + { + GoBack(); + } + + protected override bool OnBackButtonPressed() + { + GoBack(); + return true; + } + + private void GoBack() + { + if (!DoOnce()) + { + return; + } + Navigation.PopModalAsync(false); + } + + private async void RowSelected(object sender, SelectedItemChangedEventArgs e) + { + ((ListView)sender).SelectedItem = null; + if (!DoOnce()) + { + return; + } + + if (e.SelectedItem is SendView send) + { + await _vm.SelectSendAsync(send); + } + } + + private void Close_Clicked(object sender, EventArgs e) + { + GoBack(); + } + } +} diff --git a/src/App/Pages/Send/SendsPageViewModel.cs b/src/App/Pages/Send/SendsPageViewModel.cs new file mode 100644 index 000000000..fe13ba8c7 --- /dev/null +++ b/src/App/Pages/Send/SendsPageViewModel.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Bit.App.Utilities; +using Bit.Core.Abstractions; +using Bit.Core.Models.View; +using Bit.Core.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class SendsPageViewModel : BaseViewModel + { + private readonly ISearchService _searchService; + + private CancellationTokenSource _searchCancellationTokenSource; + private bool _showNoData; + private bool _showList; + + public SendsPageViewModel() + { + _searchService = ServiceContainer.Resolve("searchService"); + Sends = new ExtendedObservableCollection(); + SendOptionsCommand = new Command(SendOptionsAsync); + } + + public Command SendOptionsCommand { get; set; } + public ExtendedObservableCollection Sends { get; set; } + public Func Filter { get; set; } + + public bool ShowNoData + { + get => _showNoData; + set => SetProperty(ref _showNoData, value, additionalPropertyNames: new [] + { + nameof(ShowSearchDirection) + }); + } + + public bool ShowList + { + get => _showList; + set => SetProperty(ref _showList, value, additionalPropertyNames: new [] + { + nameof(ShowSearchDirection) + }); + } + + public bool ShowSearchDirection => !ShowList && !ShowNoData; + + public async Task InitAsync() + { + if (!string.IsNullOrWhiteSpace((Page as SendsPage).SearchBar.Text)) + { + Search((Page as SendsPage).SearchBar.Text, 200); + } + } + + public void Search(string searchText, int? timeout = null) + { + var previousCts = _searchCancellationTokenSource; + var cts = new CancellationTokenSource(); + Task.Run(async () => + { + List sends = null; + var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1; + if (searchable) + { + if (timeout != null) + { + await Task.Delay(timeout.Value); + } + if (searchText != (Page as SendsPage).SearchBar.Text) + { + return; + } + else + { + previousCts?.Cancel(); + } + try + { + sends = await _searchService.SearchSendsAsync(searchText, Filter, null, cts.Token); + cts.Token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + return; + } + } + if (sends == null) + { + sends = new List(); + } + Device.BeginInvokeOnMainThread(() => + { + Sends.ResetWithRange(sends); + ShowNoData = searchable && Sends.Count == 0; + ShowList = searchable && !ShowNoData; + }); + }, cts.Token); + _searchCancellationTokenSource = cts; + } + + public async Task SelectSendAsync(SendView send) + { + var page = new SendAddEditPage(send.Id); + await Page.Navigation.PushModalAsync(new NavigationPage(page)); + } + + private async void SendOptionsAsync(SendView send) + { + if ((Page as BaseContentPage).DoOnce()) + { + await AppHelpers.SendListOptions(Page, send); + } + } + } +} diff --git a/src/App/Pages/TabsPage.cs b/src/App/Pages/TabsPage.cs index 37f0fb08c..981245829 100644 --- a/src/App/Pages/TabsPage.cs +++ b/src/App/Pages/TabsPage.cs @@ -8,6 +8,7 @@ namespace Bit.App.Pages public class TabsPage : TabbedPage { private NavigationPage _groupingsPage; + private NavigationPage _sendGroupingsPage; private NavigationPage _generatorPage; public TabsPage(AppOptions appOptions = null, PreviousPageInfo previousPage = null) @@ -19,6 +20,13 @@ namespace Bit.App.Pages }; Children.Add(_groupingsPage); + _sendGroupingsPage = new NavigationPage(new SendGroupingsPage(true)) + { + Title = AppResources.Send, + IconImageSource = "paper_plane.png", + }; + Children.Add(_sendGroupingsPage); + _generatorPage = new NavigationPage(new GeneratorPage(true, null, this)) { Title = AppResources.Generator, diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index 24e5c7890..fff984078 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -3182,5 +3182,263 @@ namespace Bit.App.Resources { return ResourceManager.GetString("PersonalOwnershipPolicyInEffect", resourceCulture); } } + + public static string Send { + get { + return ResourceManager.GetString("Send", resourceCulture); + } + } + + public static string AllSends { + get { + return ResourceManager.GetString("AllSends", resourceCulture); + } + } + + public static string Sends { + get { + return ResourceManager.GetString("Sends", resourceCulture); + } + } + + public static string TypeText { + get { + return ResourceManager.GetString("TypeText", resourceCulture); + } + } + + public static string HideTextByDefault { + get { + return ResourceManager.GetString("HideTextByDefault", resourceCulture); + } + } + + public static string TypeFile { + get { + return ResourceManager.GetString("TypeFile", resourceCulture); + } + } + + public static string DeletionDate { + get { + return ResourceManager.GetString("DeletionDate", resourceCulture); + } + } + + public static string DeletionTime { + get { + return ResourceManager.GetString("DeletionTime", resourceCulture); + } + } + + public static string DeletionDateInfo { + get { + return ResourceManager.GetString("DeletionDateInfo", resourceCulture); + } + } + + public static string PendingDelete { + get { + return ResourceManager.GetString("PendingDelete", resourceCulture); + } + } + + public static string ExpirationDate { + get { + return ResourceManager.GetString("ExpirationDate", resourceCulture); + } + } + + public static string ExpirationTime { + get { + return ResourceManager.GetString("ExpirationTime", resourceCulture); + } + } + + public static string ExpirationDateInfo { + get { + return ResourceManager.GetString("ExpirationDateInfo", resourceCulture); + } + } + + public static string Expired { + get { + return ResourceManager.GetString("Expired", resourceCulture); + } + } + + public static string MaximumAccessCount { + get { + return ResourceManager.GetString("MaximumAccessCount", resourceCulture); + } + } + + public static string MaximumAccessCountInfo { + get { + return ResourceManager.GetString("MaximumAccessCountInfo", resourceCulture); + } + } + + public static string MaximumAccessCountReached { + get { + return ResourceManager.GetString("MaximumAccessCountReached", resourceCulture); + } + } + + public static string CurrentAccessCount { + get { + return ResourceManager.GetString("CurrentAccessCount", resourceCulture); + } + } + + public static string NewPassword { + get { + return ResourceManager.GetString("NewPassword", resourceCulture); + } + } + + public static string PasswordInfo { + get { + return ResourceManager.GetString("PasswordInfo", resourceCulture); + } + } + + public static string RemovePassword { + get { + return ResourceManager.GetString("RemovePassword", resourceCulture); + } + } + + public static string AreYouSureRemoveSendPassword { + get { + return ResourceManager.GetString("AreYouSureRemoveSendPassword", resourceCulture); + } + } + + public static string RemovingSendPassword { + get { + return ResourceManager.GetString("RemovingSendPassword", resourceCulture); + } + } + + public static string SendPasswordRemoved { + get { + return ResourceManager.GetString("SendPasswordRemoved", resourceCulture); + } + } + + public static string NotesInfo { + get { + return ResourceManager.GetString("NotesInfo", resourceCulture); + } + } + + public static string DisableSend { + get { + return ResourceManager.GetString("DisableSend", resourceCulture); + } + } + + public static string NoSends { + get { + return ResourceManager.GetString("NoSends", resourceCulture); + } + } + + public static string CopyLink { + get { + return ResourceManager.GetString("CopyLink", resourceCulture); + } + } + + public static string ShareLink { + get { + return ResourceManager.GetString("ShareLink", resourceCulture); + } + } + + public static string SearchSends { + get { + return ResourceManager.GetString("SearchSends", resourceCulture); + } + } + + public static string EditSend { + get { + return ResourceManager.GetString("EditSend", resourceCulture); + } + } + + public static string AddSend { + get { + return ResourceManager.GetString("AddSend", resourceCulture); + } + } + + public static string AreYouSureDeleteSend { + get { + return ResourceManager.GetString("AreYouSureDeleteSend", resourceCulture); + } + } + + public static string SendDeleted { + get { + return ResourceManager.GetString("SendDeleted", resourceCulture); + } + } + + public static string SendUpdated { + get { + return ResourceManager.GetString("SendUpdated", resourceCulture); + } + } + + public static string NewSendCreated { + get { + return ResourceManager.GetString("NewSendCreated", resourceCulture); + } + } + + public static string OneDay { + get { + return ResourceManager.GetString("OneDay", resourceCulture); + } + } + + public static string TwoDays { + get { + return ResourceManager.GetString("TwoDays", resourceCulture); + } + } + + public static string ThreeDays { + get { + return ResourceManager.GetString("ThreeDays", resourceCulture); + } + } + + public static string SevenDays { + get { + return ResourceManager.GetString("SevenDays", resourceCulture); + } + } + + public static string ThirtyDays { + get { + return ResourceManager.GetString("ThirtyDays", resourceCulture); + } + } + + public static string Custom { + get { + return ResourceManager.GetString("Custom", resourceCulture); + } + } + + public static string ShareOnSave { + get { + return ResourceManager.GetString("ShareOnSave", resourceCulture); + } + } } } diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index ecfb2eb11..5912a7fc5 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -1799,4 +1799,152 @@ An organization policy is affecting your ownership options. + + Send + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + All Sends + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Sends + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Text + + + When accessing the Send, hide the text by default + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + File + + + Deletion Date + + + Deletion Time + + + The Send will be permanently deleted on the specified date and time. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Pending deletion + + + Expiration Date + + + Expiration Time + + + If set, access to this Send will expire on the specified date and time. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Expired + + + Maximum Access Count + + + If set, users will no longer be able to access this send once the maximum access count is reached. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Max access count reached + + + Current Access Count + + + New Password + + + Optionally require a password for users to access this Send. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Remove Password + + + Are you sure you want to remove the password? + + + Removing password + + + Password has been removed. + + + Private notes about this Send. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Disable this Send so that no one can access it. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + There are no sends in your account. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Copy Link + + + Share Link + + + Search sends + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Edit Send + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Add Send + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Are you sure you want to delete this Send? + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Send has been deleted. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + Send updated. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + New send created. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + + + 1 day + + + 2 days + + + 3 days + + + 7 days + + + 30 days + + + Custom + + + Share this Send upon save. + 'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated. + diff --git a/src/App/Styles/Base.xaml b/src/App/Styles/Base.xaml index 8fac8c525..161f11993 100644 --- a/src/App/Styles/Base.xaml +++ b/src/App/Styles/Base.xaml @@ -359,4 +359,11 @@ + diff --git a/src/App/Utilities/AppHelpers.cs b/src/App/Utilities/AppHelpers.cs index 3703eac39..cbe91c38e 100644 --- a/src/App/Utilities/AppHelpers.cs +++ b/src/App/Utilities/AppHelpers.cs @@ -1,4 +1,5 @@ -using Bit.App.Abstractions; +using System; +using Bit.App.Abstractions; using Bit.App.Pages; using Bit.App.Resources; using Bit.Core; @@ -9,6 +10,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Bit.App.Models; +using Bit.Core.Exceptions; +using Xamarin.Essentials; using Xamarin.Forms; namespace Bit.App.Utilities @@ -130,6 +133,143 @@ namespace Bit.App.Utilities return selection; } + public static async Task SendListOptions(ContentPage page, SendView send) + { + var platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + var vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); + var options = new List { AppResources.Edit }; + options.Add(AppResources.CopyLink); + options.Add(AppResources.ShareLink); + if (send.HasPassword) + { + options.Add(AppResources.RemovePassword); + } + + var selection = await page.DisplayActionSheet(send.Name, AppResources.Cancel, AppResources.Delete, + options.ToArray()); + if (await vaultTimeoutService.IsLockedAsync()) + { + platformUtilsService.ShowToast("info", null, AppResources.VaultIsLocked); + } + else if (selection == AppResources.Edit) + { + await page.Navigation.PushModalAsync(new NavigationPage(new SendAddEditPage(send.Id))); + } + else if (selection == AppResources.CopyLink) + { + await platformUtilsService.CopyToClipboardAsync(GetSendUrl(send)); + platformUtilsService.ShowToast("info", null, + string.Format(AppResources.ValueHasBeenCopied, AppResources.ShareLink)); + } + else if (selection == AppResources.ShareLink) + { + await ShareSendUrl(send); + } + else if (selection == AppResources.RemovePassword) + { + await RemoveSendPasswordAsync(send.Id); + } + else if (selection == AppResources.Delete) + { + await DeleteSendAsync(send.Id); + } + return selection; + } + + public static string GetSendUrl(SendView send) + { + var environmentService = ServiceContainer.Resolve("environmentService"); + return environmentService.BaseUrl + "/#/send/" + send.AccessId + "/" + send.UrlB64Key; + } + + public static async Task ShareSendUrl(SendView send) + { + await Share.RequestAsync(new ShareTextRequest + { + Uri = new Uri(GetSendUrl(send)).ToString(), + Title = AppResources.Send + " " + send.Name, + Subject = send.Name + }); + } + + public static async Task RemoveSendPasswordAsync(string sendId) + { + var platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + var deviceActionService = ServiceContainer.Resolve("deviceActionService"); + var sendService = ServiceContainer.Resolve("sendService"); + + if (Connectivity.NetworkAccess == NetworkAccess.None) + { + await platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, + AppResources.InternetConnectionRequiredTitle); + return false; + } + var confirmed = await platformUtilsService.ShowDialogAsync( + AppResources.AreYouSureRemoveSendPassword, + null, AppResources.Yes, AppResources.Cancel); + if (!confirmed) + { + return false; + } + try + { + await deviceActionService.ShowLoadingAsync(AppResources.RemovingSendPassword); + await sendService.RemovePasswordWithServerAsync(sendId); + await deviceActionService.HideLoadingAsync(); + platformUtilsService.ShowToast("success", null, AppResources.SendPasswordRemoved); + return true; + } + catch (ApiException e) + { + await deviceActionService.HideLoadingAsync(); + if (e?.Error != null) + { + await platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), + AppResources.AnErrorHasOccurred); + } + } + return false; + } + + public static async Task DeleteSendAsync(string sendId) + { + var platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + var deviceActionService = ServiceContainer.Resolve("deviceActionService"); + var sendService = ServiceContainer.Resolve("sendService"); + + if (Connectivity.NetworkAccess == NetworkAccess.None) + { + await platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, + AppResources.InternetConnectionRequiredTitle); + return false; + } + var confirmed = await platformUtilsService.ShowDialogAsync( + AppResources.AreYouSureDeleteSend, + null, AppResources.Yes, AppResources.Cancel); + if (!confirmed) + { + return false; + } + try + { + await deviceActionService.ShowLoadingAsync(AppResources.Deleting); + await sendService.DeleteWithServerAsync(sendId); + await deviceActionService.HideLoadingAsync(); + platformUtilsService.ShowToast("success", null, AppResources.SendDeleted); + return true; + } + catch (ApiException e) + { + await deviceActionService.HideLoadingAsync(); + if (e?.Error != null) + { + await platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), + AppResources.AnErrorHasOccurred); + } + } + return false; + } + public static async Task PerformUpdateTasksAsync(ISyncService syncService, IDeviceActionService deviceActionService, IStorageService storageService) { @@ -143,7 +283,7 @@ namespace Bit.App.Utilities { await storageService.SaveAsync(Constants.VaultTimeoutKey, 15); } - + var currentAction = await storageService.GetAsync(Constants.VaultTimeoutActionKey); if (currentAction == null) { diff --git a/src/Core/Abstractions/ISearchService.cs b/src/Core/Abstractions/ISearchService.cs index ee7b52389..a13ec0cc6 100644 --- a/src/Core/Abstractions/ISearchService.cs +++ b/src/Core/Abstractions/ISearchService.cs @@ -15,5 +15,9 @@ namespace Bit.Core.Abstractions List ciphers = null, CancellationToken ct = default); List SearchCiphersBasic(List ciphers, string query, CancellationToken ct = default, bool deleted = false); + Task> SearchSendsAsync(string query, Func filter = null, + List sends = null, CancellationToken ct = default); + List SearchSendsBasic(List sends, string query, + CancellationToken ct = default, bool deleted = false); } } diff --git a/src/Core/Abstractions/ISendService.cs b/src/Core/Abstractions/ISendService.cs index ef0e1c54e..14cbddf4f 100644 --- a/src/Core/Abstractions/ISendService.cs +++ b/src/Core/Abstractions/ISendService.cs @@ -9,12 +9,12 @@ namespace Bit.Core.Abstractions public interface ISendService { void ClearCache(); - Task<(Send send, CipherString encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, string password, + Task<(Send send, byte[] encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, string password, SymmetricCryptoKey key = null); Task GetAsync(string id); Task> GetAllAsync(); Task> GetAllDecryptedAsync(); - Task SaveWithServerAsync(Send sendData, byte[] encryptedFileData); + Task SaveWithServerAsync(Send sendData, byte[] encryptedFileData); Task UpsertAsync(params SendData[] send); Task ReplaceAsync(Dictionary sends); Task ClearAsync(string userId); diff --git a/src/Core/Models/Response/SendResponse.cs b/src/Core/Models/Response/SendResponse.cs index 508c49a43..ce38f664f 100644 --- a/src/Core/Models/Response/SendResponse.cs +++ b/src/Core/Models/Response/SendResponse.cs @@ -15,10 +15,10 @@ namespace Bit.Core.Models.Response public SendTextApi Text { get; set; } public string Key { get; set; } public int? MaxAccessCount { get; set; } - public int AccessCount { get; internal set; } - public DateTime RevisionDate { get; internal set; } - public DateTime? ExpirationDate { get; internal set; } - public DateTime DeletionDate { get; internal set; } + public int AccessCount { get; set; } + public DateTime RevisionDate { get; set; } + public DateTime? ExpirationDate { get; set; } + public DateTime DeletionDate { get; set; } public string Password { get; set; } public bool Disabled { get; set; } } diff --git a/src/Core/Models/View/SendView.cs b/src/Core/Models/View/SendView.cs index fff949d6a..49d589f37 100644 --- a/src/Core/Models/View/SendView.cs +++ b/src/Core/Models/View/SendView.cs @@ -7,6 +7,8 @@ namespace Bit.Core.Models.View { public class SendView : View { + public SendView() { } + public SendView(Send send) : base() { Id = send.Id; @@ -38,8 +40,10 @@ namespace Bit.Core.Models.View public string Password { get; set; } public bool Disabled { get; set; } public string UrlB64Key => Key == null ? null : CoreHelpers.Base64UrlEncode(Key); + public bool HasPassword => Password?.Length > 0; public bool MaxAccessCountReached => MaxAccessCount.HasValue && AccessCount >= MaxAccessCount.Value; public bool Expired => ExpirationDate.HasValue && ExpirationDate.Value <= DateTime.UtcNow; public bool PendingDelete => DeletionDate <= DateTime.UtcNow; + public string DisplayDate => DeletionDate.ToLocalTime().ToString("MMM d, yyyy, h:mm tt"); } } diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index d1a09e06b..8f5245593 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -230,7 +230,7 @@ namespace Bit.Core.Services SendAsync(HttpMethod.Put, $"/sends/{id}", request, true, true); public Task PutSendRemovePasswordAsync(string id) => - SendAsync(HttpMethod.Put, $"/sends/{id}", null, true, true); + SendAsync(HttpMethod.Put, $"/sends/{id}/remove-password", null, true, true); public Task DeleteSendAsync(string id) => SendAsync(HttpMethod.Delete, $"/sends/{id}", null, true, false); diff --git a/src/Core/Services/CryptoService.cs b/src/Core/Services/CryptoService.cs index 7195071d3..3369e861c 100644 --- a/src/Core/Services/CryptoService.cs +++ b/src/Core/Services/CryptoService.cs @@ -429,7 +429,7 @@ namespace Bit.Core.Services public async Task MakeSendKeyAsync(byte[] keyMaterial) { - var sendKey = await _cryptoFunctionService.HkdfAsync(keyMaterial, "bitwarden-send", "send", 65, HkdfAlgorithm.Sha256); + var sendKey = await _cryptoFunctionService.HkdfAsync(keyMaterial, "bitwarden-send", "send", 64, HkdfAlgorithm.Sha256); return new SymmetricCryptoKey(sendKey); } diff --git a/src/Core/Services/SearchService.cs b/src/Core/Services/SearchService.cs index d71676a57..689fbf59a 100644 --- a/src/Core/Services/SearchService.cs +++ b/src/Core/Services/SearchService.cs @@ -11,11 +11,14 @@ namespace Bit.Core.Services public class SearchService : ISearchService { private readonly ICipherService _cipherService; + private readonly ISendService _sendService; public SearchService( - ICipherService cipherService) + ICipherService cipherService, + ISendService sendService) { _cipherService = cipherService; + _sendService = sendService; } public void ClearIndex() @@ -94,5 +97,61 @@ namespace Bit.Core.Services return false; }).ToList(); } + + public async Task> SearchSendsAsync(string query, Func filter = null, + List sends = null, CancellationToken ct = default) + { + var results = new List(); + if (query != null) + { + query = query.Trim().ToLower(); + } + if (query == string.Empty) + { + query = null; + } + if (sends == null) + { + sends = await _sendService.GetAllDecryptedAsync(); + } + + ct.ThrowIfCancellationRequested(); + if (filter != null) + { + sends = sends.Where(filter).ToList(); + } + + ct.ThrowIfCancellationRequested(); + if (!IsSearchable(query)) + { + return sends; + } + + return SearchSendsBasic(sends, query); + } + + public List SearchSendsBasic(List sends, string query, CancellationToken ct = default, + bool deleted = false) + { + ct.ThrowIfCancellationRequested(); + query = query.Trim().ToLower(); + return sends.Where(s => + { + ct.ThrowIfCancellationRequested(); + if (s.Name?.ToLower().Contains(query) ?? false) + { + return true; + } + if (s.Text?.Text?.ToLower().Contains(query) ?? false) + { + return true; + } + if (s.File?.FileName?.ToLower()?.Contains(query) ?? false) + { + return true; + } + return false; + }).ToList(); + } } } diff --git a/src/Core/Services/SendService.cs b/src/Core/Services/SendService.cs index 94281a14e..15e842162 100644 --- a/src/Core/Services/SendService.cs +++ b/src/Core/Services/SendService.cs @@ -77,7 +77,7 @@ namespace Bit.Core.Services await DeleteAsync(id); } - public async Task<(Send send, CipherString encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, + public async Task<(Send send, byte[] encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, string password, SymmetricCryptoKey key = null) { if (model.Key == null) @@ -91,17 +91,20 @@ namespace Bit.Core.Services Id = model.Id, Type = model.Type, Disabled = model.Disabled, + DeletionDate = model.DeletionDate, + ExpirationDate = model.ExpirationDate, MaxAccessCount = model.MaxAccessCount, Key = await _cryptoService.EncryptAsync(model.Key, key), Name = await _cryptoService.EncryptAsync(model.Name, model.CryptoKey), Notes = await _cryptoService.EncryptAsync(model.Notes, model.CryptoKey), }; - CipherString encryptedFileData = null; + byte[] encryptedFileData = null; if (password != null) { + var kdfIterations = await _userService.GetKdfIterationsAsync() ?? 100000; var passwordHash = await _cryptoFunctionService.Pbkdf2Async(password, model.Key, - CryptoHashAlgorithm.Sha256, 100000); + CryptoHashAlgorithm.Sha256, kdfIterations); send.Password = Convert.ToBase64String(passwordHash); } @@ -119,7 +122,7 @@ namespace Bit.Core.Services if (fileData != null) { send.File.FileName = await _cryptoService.EncryptAsync(model.File.FileName, model.CryptoKey); - encryptedFileData = await _cryptoService.EncryptAsync(fileData, model.CryptoKey); + encryptedFileData = await _cryptoService.EncryptToBytesAsync(fileData, model.CryptoKey); } break; default: @@ -133,7 +136,7 @@ namespace Bit.Core.Services { var userId = await _userService.GetUserIdAsync(); var sends = await _storageService.GetAsync>(GetSendKey(userId)); - return sends.Select(kvp => new Send(kvp.Value)).ToList(); + return sends?.Select(kvp => new Send(kvp.Value)).ToList() ?? new List(); } public async Task> GetAllDecryptedAsync() @@ -161,7 +164,7 @@ namespace Bit.Core.Services async Task decryptAndAddSendAsync(Send send) => decSends.Add(await send.DecryptAsync()); await Task.WhenAll((await GetAllAsync()).Select(s => decryptAndAddSendAsync(s))); - decSends.OrderBy(s => s, new SendLocaleComparer(_i18nService)).ToList(); + decSends = decSends.OrderBy(s => s, new SendLocaleComparer(_i18nService)).ToList(); _decryptedSendsCache = decSends; return _decryptedSendsCache; } @@ -190,9 +193,8 @@ namespace Bit.Core.Services _decryptedSendsCache = null; } - public async Task SaveWithServerAsync(Send send, byte[] encryptedFileData) + public async Task SaveWithServerAsync(Send send, byte[] encryptedFileData) { - var request = new SendRequest(send); SendResponse response; if (send.Id == null) @@ -223,6 +225,7 @@ namespace Bit.Core.Services var userId = await _userService.GetUserIdAsync(); await UpsertAsync(new SendData(response, userId)); + return response.Id; } public async Task UpsertAsync(params SendData[] sends) diff --git a/src/Core/Services/SyncService.cs b/src/Core/Services/SyncService.cs index d60ed6764..408ab05eb 100644 --- a/src/Core/Services/SyncService.cs +++ b/src/Core/Services/SyncService.cs @@ -374,7 +374,11 @@ namespace Bit.Core.Services await _policyService.Replace(policies); } - private Task SyncSendsAsync(string userId, List sends) => - _sendService.ReplaceAsync(sends.ToDictionary(s => userId, s => new SendData(s, userId))); + private async Task SyncSendsAsync(string userId, List response) + { + var sends = response?.ToDictionary(s => s.Id, s => new SendData(s, userId)) ?? + new Dictionary(); + await _sendService.ReplaceAsync(sends); + } } } diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index 0d8b8725b..0ae61b84a 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -45,7 +45,9 @@ namespace Bit.Core.Utilities var folderService = new FolderService(cryptoService, userService, apiService, storageService, i18nService, cipherService); var collectionService = new CollectionService(cryptoService, userService, storageService, i18nService); - searchService = new SearchService(cipherService); + var sendService = new SendService(cryptoService, userService, apiService, storageService, i18nService, + cryptoFunctionService); + searchService = new SearchService(cipherService, sendService); var vaultTimeoutService = new VaultTimeoutService(cryptoService, userService, platformUtilsService, storageService, folderService, cipherService, collectionService, searchService, messagingService, tokenService, null, (expired) => @@ -54,8 +56,6 @@ namespace Bit.Core.Utilities return Task.FromResult(0); }); var policyService = new PolicyService(storageService, userService); - var sendService = new SendService(cryptoService, userService, apiService, storageService, i18nService, - cryptoFunctionService); var syncService = new SyncService(userService, apiService, settingsService, folderService, cipherService, cryptoService, collectionService, storageService, messagingService, policyService, sendService, (bool expired) => @@ -84,6 +84,7 @@ namespace Bit.Core.Utilities Register("cipherService", cipherService); Register("folderService", folderService); Register("collectionService", collectionService); + Register("sendService", sendService); Register("searchService", searchService); Register("policyService", policyService); Register("syncService", syncService); diff --git a/src/iOS.Core/Renderers/ExtendedDatePickerRenderer.cs b/src/iOS.Core/Renderers/ExtendedDatePickerRenderer.cs new file mode 100644 index 000000000..c2e3bcc78 --- /dev/null +++ b/src/iOS.Core/Renderers/ExtendedDatePickerRenderer.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using Bit.App.Controls; +using Bit.iOS.Core.Renderers; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(ExtendedDatePicker), typeof(ExtendedDatePickerRenderer))] +namespace Bit.iOS.Core.Renderers +{ + public class ExtendedDatePickerRenderer : DatePickerRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + if (Control != null && Element is ExtendedDatePicker element) + { + // center text + Control.TextAlignment = UITextAlignment.Center; + + // use placeholder until NullableDate set + if (!element.NullableDate.HasValue) + { + Control.Text = element.PlaceHolder; + } + + // force use of wheel picker on iOS 14+ + // TODO remove this when we upgrade to X.F 5 SR-1 + // https://github.com/xamarin/Xamarin.Forms/issues/12258#issuecomment-700168665 + try + { + if (UIDevice.CurrentDevice.CheckSystemVersion(13, 2)) + { + var picker = (UIDatePicker)Control.InputView; + picker.PreferredDatePickerStyle = UIDatePickerStyle.Wheels; + } + } + catch { } + } + } + + protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == DatePicker.DateProperty.PropertyName || + e.PropertyName == DatePicker.FormatProperty.PropertyName) + { + if (Control != null && Element is ExtendedDatePicker element) + { + if (Element.Format == element.PlaceHolder) + { + Control.Text = element.PlaceHolder; + return; + } + } + } + base.OnElementPropertyChanged(sender, e); + } + } +} diff --git a/src/iOS.Core/Renderers/ExtendedTimePickerRenderer.cs b/src/iOS.Core/Renderers/ExtendedTimePickerRenderer.cs new file mode 100644 index 000000000..c43216f88 --- /dev/null +++ b/src/iOS.Core/Renderers/ExtendedTimePickerRenderer.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using Bit.App.Controls; +using Bit.iOS.Core.Renderers; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(ExtendedTimePicker), typeof(ExtendedTimePickerRenderer))] +namespace Bit.iOS.Core.Renderers +{ + public class ExtendedTimePickerRenderer : TimePickerRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + if (Control != null && Element is ExtendedTimePicker element) + { + // center text + Control.TextAlignment = UITextAlignment.Center; + + // use placeholder until NullableTime set + if (!element.NullableTime.HasValue) + { + Control.Text = element.PlaceHolder; + } + + // force use of wheel picker on iOS 14+ + // TODO remove this when we upgrade to X.F 5 SR-1 + // https://github.com/xamarin/Xamarin.Forms/issues/12258#issuecomment-700168665 + try + { + if (UIDevice.CurrentDevice.CheckSystemVersion(13, 2)) + { + var picker = (UIDatePicker)Control.InputView; + picker.PreferredDatePickerStyle = UIDatePickerStyle.Wheels; + } + } + catch { } + } + } + + protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == TimePicker.TimeProperty.PropertyName || + e.PropertyName == TimePicker.FormatProperty.PropertyName) + { + if (Control != null && Element is ExtendedTimePicker element) + { + if (Element.Format == element.PlaceHolder) + { + Control.Text = element.PlaceHolder; + return; + } + } + } + base.OnElementPropertyChanged(sender, e); + } + } +} diff --git a/src/iOS.Core/iOS.Core.csproj b/src/iOS.Core/iOS.Core.csproj index 132168b40..6aa4fbae6 100644 --- a/src/iOS.Core/iOS.Core.csproj +++ b/src/iOS.Core/iOS.Core.csproj @@ -155,12 +155,14 @@ + + diff --git a/src/iOS/Resources/paper_plane.png b/src/iOS/Resources/paper_plane.png new file mode 100644 index 000000000..646f07d2b Binary files /dev/null and b/src/iOS/Resources/paper_plane.png differ diff --git a/src/iOS/Resources/paper_plane@2x.png b/src/iOS/Resources/paper_plane@2x.png new file mode 100644 index 000000000..37b5f342a Binary files /dev/null and b/src/iOS/Resources/paper_plane@2x.png differ diff --git a/src/iOS/Resources/paper_plane@3x.png b/src/iOS/Resources/paper_plane@3x.png new file mode 100644 index 000000000..c816000e7 Binary files /dev/null and b/src/iOS/Resources/paper_plane@3x.png differ diff --git a/src/iOS/iOS.csproj b/src/iOS/iOS.csproj index 1e6c3a996..682ded2ef 100644 --- a/src/iOS/iOS.csproj +++ b/src/iOS/iOS.csproj @@ -267,6 +267,15 @@ + + + + + + + + + diff --git a/test/Core.Test/Services/SendServiceTests.cs b/test/Core.Test/Services/SendServiceTests.cs index b617cd0b4..83066bf63 100644 --- a/test/Core.Test/Services/SendServiceTests.cs +++ b/test/Core.Test/Services/SendServiceTests.cs @@ -339,6 +339,8 @@ namespace Bit.Core.Test.Services new CipherString($"{prefix}{Convert.ToBase64String(secret)}{Convert.ToBase64String(key.Key)}"); CipherString encrypt(string secret, SymmetricCryptoKey key) => new CipherString($"{prefix}{secret}{Convert.ToBase64String(key.Key)}"); + byte[] encryptFileBytes(byte[] secret, SymmetricCryptoKey key) => + secret.Concat(key.Key).ToArray(); sutProvider.GetDependency().Pbkdf2Async(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(info => getPbkdf((string)info[0], (byte[])info[1])); @@ -346,12 +348,14 @@ namespace Bit.Core.Test.Services .Returns(info => encryptBytes((byte[])info[0], (SymmetricCryptoKey)info[1])); sutProvider.GetDependency().EncryptAsync(Arg.Any(), Arg.Any()) .Returns(info => encrypt((string)info[0], (SymmetricCryptoKey)info[1])); + sutProvider.GetDependency().EncryptToBytesAsync(Arg.Any(), Arg.Any()) + .Returns(info => encryptFileBytes((byte[])info[0], (SymmetricCryptoKey)info[1])); var (send, encryptedFileData) = await sutProvider.Sut.EncryptAsync(view, fileData, view.Password, privateKey); TestHelper.AssertPropertyEqual(view, send, "Password", "Key", "Name", "Notes", "Text", "File", "AccessCount", "AccessId", "CryptoKey", "RevisionDate", "DeletionDate", "ExpirationDate", "UrlB64Key", - "MaxAccessCountReached", "Expired", "PendingDelete"); + "MaxAccessCountReached", "Expired", "PendingDelete", "HasPassword", "DisplayDate"); Assert.Equal(Convert.ToBase64String(getPbkdf(view.Password, view.Key)), send.Password); TestHelper.AssertPropertyEqual(encryptBytes(view.Key, privateKey), send.Key); TestHelper.AssertPropertyEqual(encrypt(view.Name, view.CryptoKey), send.Name); @@ -366,7 +370,7 @@ namespace Bit.Core.Test.Services case SendType.File: // Only set filename TestHelper.AssertPropertyEqual(encrypt(view.File.FileName, view.CryptoKey), send.File.FileName); - TestHelper.AssertPropertyEqual(encryptBytes(fileData, view.CryptoKey), encryptedFileData); + Assert.Equal(encryptFileBytes(fileData, view.CryptoKey), encryptedFileData); break; default: throw new Exception("Untested send type");