diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index 67b84ff55..90b3df978 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -17,6 +17,7 @@ using Bit.App.Models.Page; using Bit.App; using Android.Nfc; using Android.Views.InputMethods; +using System.IO; namespace Bit.Android { @@ -215,6 +216,25 @@ namespace Bit.Android ZXing.Net.Mobile.Forms.Android.PermissionsHandler.OnRequestPermissionsResult(requestCode, permissions, grantResults); } + protected override void OnActivityResult(int requestCode, Result resultCode, Intent data) + { + if(requestCode == Constants.SelectFileRequestCode && resultCode == Result.Ok) + { + global::Android.Net.Uri uri = null; + if(data != null) + { + uri = data.Data; + using(var stream = ContentResolver.OpenInputStream(uri)) + using(var memoryStream = new MemoryStream()) + { + stream.CopyTo(memoryStream); + MessagingCenter.Send(Xamarin.Forms.Application.Current, "SelectFileResult", + new Tuple(memoryStream.ToArray(), Utilities.GetFileName(ApplicationContext, uri))); + } + } + } + } + public void RateApp() { try diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index 6d04709e0..7d8f28bc0 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -6,6 +6,7 @@ using Android.Webkit; using Plugin.CurrentActivity; using System.IO; using Android.Support.V4.Content; +using Bit.App; namespace Bit.Android.Services { @@ -123,9 +124,12 @@ namespace Bit.Android.Services } } - public byte[] SelectFile() + public void SelectFile() { - return null; + var intent = new Intent(Intent.ActionOpenDocument); + intent.AddCategory(Intent.CategoryOpenable); + intent.SetType("*/*"); + CrossCurrentActivity.Current.Activity.StartActivityForResult(intent, Constants.SelectFileRequestCode); } } } diff --git a/src/Android/Utilities.cs b/src/Android/Utilities.cs index f7fecc059..bce009ec1 100644 --- a/src/Android/Utilities.cs +++ b/src/Android/Utilities.cs @@ -4,6 +4,7 @@ using Android.Content; using Java.Security; using System.IO; using Android.Nfc; +using Android.Provider; namespace Bit.Android { @@ -101,5 +102,28 @@ namespace Bit.Android return message; } + + public static string GetFileName(Context context, global::Android.Net.Uri uri) + { + string name = null; + string[] projection = { MediaStore.MediaColumns.DisplayName }; + var metaCursor = context.ContentResolver.Query(uri, projection, null, null, null); + if(metaCursor != null) + { + try + { + if(metaCursor.MoveToFirst()) + { + name = metaCursor.GetString(0); + } + } + finally + { + metaCursor.Close(); + } + } + + return name; + } } } \ No newline at end of file diff --git a/src/App/Abstractions/Repositories/ICipherApiRepository.cs b/src/App/Abstractions/Repositories/ICipherApiRepository.cs index e695d78de..f1c8346e2 100644 --- a/src/App/Abstractions/Repositories/ICipherApiRepository.cs +++ b/src/App/Abstractions/Repositories/ICipherApiRepository.cs @@ -8,5 +8,7 @@ namespace Bit.App.Abstractions { Task> GetByIdAsync(string id); Task>> GetAsync(); + Task> PostAttachmentAsync(string cipherId, byte[] data, string fileName); + Task DeleteAttachmentAsync(string cipherId, string attachmentId); } } \ No newline at end of file diff --git a/src/App/Abstractions/Services/ICryptoService.cs b/src/App/Abstractions/Services/ICryptoService.cs index 2bd6457d5..89ace6a32 100644 --- a/src/App/Abstractions/Services/ICryptoService.cs +++ b/src/App/Abstractions/Services/ICryptoService.cs @@ -22,6 +22,7 @@ namespace Bit.App.Abstractions byte[] DecryptToBytes(byte[] encyptedValue, SymmetricCryptoKey key = null); byte[] RsaDecryptToBytes(CipherString encyptedValue, byte[] privateKey); CipherString Encrypt(string plaintextValue, SymmetricCryptoKey key = null); + byte[] EncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key = null); SymmetricCryptoKey MakeKeyFromPassword(string password, string salt); string MakeKeyFromPasswordBase64(string password, string salt); byte[] HashPassword(SymmetricCryptoKey key, string password); diff --git a/src/App/Abstractions/Services/IDeviceActionService.cs b/src/App/Abstractions/Services/IDeviceActionService.cs index 919a7640f..916db5b15 100644 --- a/src/App/Abstractions/Services/IDeviceActionService.cs +++ b/src/App/Abstractions/Services/IDeviceActionService.cs @@ -1,11 +1,13 @@ -namespace Bit.App.Abstractions +using System; + +namespace Bit.App.Abstractions { public interface IDeviceActionService { void CopyToClipboard(string text); bool OpenFile(byte[] fileData, string id, string fileName); bool CanOpenFile(string fileName); - byte[] SelectFile(); + void SelectFile(); void ClearCache(); } } diff --git a/src/App/Abstractions/Services/ILoginService.cs b/src/App/Abstractions/Services/ILoginService.cs index 0eb48ead6..6e8aae311 100644 --- a/src/App/Abstractions/Services/ILoginService.cs +++ b/src/App/Abstractions/Services/ILoginService.cs @@ -15,5 +15,7 @@ namespace Bit.App.Abstractions Task> SaveAsync(Login login); Task DeleteAsync(string id); Task DownloadAndDecryptAttachmentAsync(string url, string orgId = null); + Task> EncryptAndSaveAttachmentAsync(Login login, byte[] data, string fileName); + Task DeleteAttachmentAsync(Login login, string attachmentId); } } diff --git a/src/App/App.csproj b/src/App/App.csproj index 1b6b0475a..e8ef0b574 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -84,6 +84,7 @@ + @@ -121,6 +122,7 @@ + @@ -162,6 +164,7 @@ + diff --git a/src/App/Constants.cs b/src/App/Constants.cs index aaa1f0282..08f1b497c 100644 --- a/src/App/Constants.cs +++ b/src/App/Constants.cs @@ -33,5 +33,7 @@ public const string Locked = "other:locked"; public const string LastLoginEmail = "other:lastLoginEmail"; public const string LastSync = "other:lastSync"; + + public const int SelectFileRequestCode = 42; } } diff --git a/src/App/Controls/LabeledRightDetailCell.cs b/src/App/Controls/LabeledRightDetailCell.cs index f7e97e62e..dcb6cd9ae 100644 --- a/src/App/Controls/LabeledRightDetailCell.cs +++ b/src/App/Controls/LabeledRightDetailCell.cs @@ -5,7 +5,7 @@ namespace Bit.App.Controls { public class LabeledRightDetailCell : ExtendedViewCell { - public LabeledRightDetailCell() + public LabeledRightDetailCell(bool showIcon = true) { Label = new Label { @@ -22,32 +22,38 @@ namespace Bit.App.Controls VerticalOptions = LayoutOptions.Center }; - Icon = new CachedImage - { - WidthRequest = 16, - HeightRequest = 16, - HorizontalOptions = LayoutOptions.End, - VerticalOptions = LayoutOptions.Center, - Margin = new Thickness(5, 0, 0, 0) - }; - - var stackLayout = new StackLayout + StackLayout = new StackLayout { Orientation = StackOrientation.Horizontal, Padding = new Thickness(15, 10), - Children = { Label, Detail, Icon } + Children = { Label, Detail } }; + if(showIcon) + { + Icon = new CachedImage + { + WidthRequest = 16, + HeightRequest = 16, + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(5, 0, 0, 0) + }; + + StackLayout.Children.Add(Icon); + } + if(Device.RuntimePlatform == Device.Android) { Label.TextColor = Color.Black; } - View = stackLayout; + View = StackLayout; } public Label Label { get; private set; } public Label Detail { get; private set; } public CachedImage Icon { get; private set; } + public StackLayout StackLayout { get; private set; } } } diff --git a/src/App/Controls/VaultAttachmentsViewCell.cs b/src/App/Controls/VaultAttachmentsViewCell.cs new file mode 100644 index 000000000..5ed05d1bf --- /dev/null +++ b/src/App/Controls/VaultAttachmentsViewCell.cs @@ -0,0 +1,21 @@ +using Bit.App.Models.Page; +using Xamarin.Forms; + +namespace Bit.App.Controls +{ + public class VaultAttachmentsViewCell : LabeledRightDetailCell + { + public VaultAttachmentsViewCell() + : base(false) + { + Label.SetBinding(Label.TextProperty, nameof(VaultAttachmentsPageModel.Attachment.Name)); + Detail.SetBinding(Label.TextProperty, nameof(VaultAttachmentsPageModel.Attachment.SizeName)); + BackgroundColor = Color.White; + + if(Device.RuntimePlatform == Device.iOS) + { + StackLayout.BackgroundColor = Color.White; + } + } + } +} diff --git a/src/App/Models/Page/VaultAttachmentsPageModel.cs b/src/App/Models/Page/VaultAttachmentsPageModel.cs new file mode 100644 index 000000000..80e20f10e --- /dev/null +++ b/src/App/Models/Page/VaultAttachmentsPageModel.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Bit.App.Models.Page +{ + public class VaultAttachmentsPageModel + { + public class Attachment : List + { + public string Id { get; set; } + public string Name { get; set; } + public string SizeName { get; set; } + public long Size { get; set; } + public string Url { get; set; } + + public Attachment(Models.Attachment attachment) + { + Id = attachment.Id; + Name = attachment.FileName?.Decrypt(); + SizeName = attachment.SizeName; + Size = attachment.Size; + Url = attachment.Url; + } + } + } +} diff --git a/src/App/Pages/Settings/SettingsFeaturesPage.cs b/src/App/Pages/Settings/SettingsFeaturesPage.cs index 21d3666d6..0fc636447 100644 --- a/src/App/Pages/Settings/SettingsFeaturesPage.cs +++ b/src/App/Pages/Settings/SettingsFeaturesPage.cs @@ -217,6 +217,23 @@ namespace Bit.App.Pages private void Layout_LayoutChanged(object sender, EventArgs e) { AnalyticsLabel.WidthRequest = StackLayout.Bounds.Width - AnalyticsLabel.Bounds.Left * 2; + CopyTotpLabel.WidthRequest = StackLayout.Bounds.Width - CopyTotpLabel.Bounds.Left * 2; + + if(AutofillAlwaysLabel != null) + { + AutofillAlwaysLabel.WidthRequest = StackLayout.Bounds.Width - AutofillAlwaysLabel.Bounds.Left * 2; + } + + if(AutofillPasswordFieldLabel != null) + { + AutofillPasswordFieldLabel.WidthRequest = StackLayout.Bounds.Width - AutofillPasswordFieldLabel.Bounds.Left * 2; + } + + if(AutofillPersistNotificationLabel != null) + { + AutofillPersistNotificationLabel.WidthRequest = + StackLayout.Bounds.Width - AutofillPersistNotificationLabel.Bounds.Left * 2; + } } private void AnalyticsCell_Changed(object sender, ToggledEventArgs e) diff --git a/src/App/Pages/Vault/VaultAttachmentsPage.cs b/src/App/Pages/Vault/VaultAttachmentsPage.cs new file mode 100644 index 000000000..6fa98a3f2 --- /dev/null +++ b/src/App/Pages/Vault/VaultAttachmentsPage.cs @@ -0,0 +1,288 @@ +using System; +using System.Linq; +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.Connectivity.Abstractions; +using System.Collections.Generic; +using Bit.App.Models; +using System.Threading.Tasks; + +namespace Bit.App.Pages +{ + public class VaultAttachmentsPage : ExtendedContentPage + { + private readonly ILoginService _loginService; + private readonly IUserDialogs _userDialogs; + private readonly IConnectivity _connectivity; + private readonly IDeviceActionService _deviceActiveService; + private readonly IGoogleAnalyticsService _googleAnalyticsService; + private readonly string _loginId; + private Login _login; + private byte[] _fileBytes; + private DateTime? _lastAction; + + public VaultAttachmentsPage(string loginId) + : base(true) + { + _loginId = loginId; + _loginService = Resolver.Resolve(); + _connectivity = Resolver.Resolve(); + _userDialogs = Resolver.Resolve(); + _deviceActiveService = Resolver.Resolve(); + _googleAnalyticsService = Resolver.Resolve(); + + Init(); + } + + public ExtendedObservableCollection PresentationAttchments { get; private set; } + = new ExtendedObservableCollection(); + public ListView ListView { get; set; } + public StackLayout NoDataStackLayout { get; set; } + public StackLayout AddNewStackLayout { get; set; } + public Label FileLabel { get; set; } + public ExtendedTableView NewTable { get; set; } + public Label NoDataLabel { get; set; } + + private void Init() + { + SubscribeFileResult(true); + var selectButton = new ExtendedButton + { + Text = AppResources.ChooseFile, + Command = new Command(() => _deviceActiveService.SelectFile()), + Style = (Style)Application.Current.Resources["btn-primaryAccent"], + FontSize = Device.GetNamedSize(NamedSize.Medium, typeof(Button)) + }; + + FileLabel = new Label + { + Text = AppResources.NoFileChosen, + Style = (Style)Application.Current.Resources["text-muted"], + FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)), + HorizontalTextAlignment = TextAlignment.Center + }; + + AddNewStackLayout = new StackLayout + { + Children = { selectButton, FileLabel }, + Orientation = StackOrientation.Vertical, + Padding = new Thickness(20, Helpers.OnPlatform(iOS: 10, Android: 20), 20, 20), + VerticalOptions = LayoutOptions.Start + }; + + NewTable = new ExtendedTableView + { + Intent = TableIntent.Settings, + HasUnevenRows = true, + NoFooter = true, + EnableScrolling = false, + EnableSelection = false, + VerticalOptions = LayoutOptions.Start, + Margin = new Thickness(0, Helpers.OnPlatform(iOS: 10, Android: 30), 0, 0), + Root = new TableRoot + { + new TableSection(AppResources.AddNewAttachment) + { + new ExtendedViewCell + { + View = AddNewStackLayout, + BackgroundColor = Color.White + } + } + } + }; + + ListView = new ListView(ListViewCachingStrategy.RecycleElement) + { + ItemsSource = PresentationAttchments, + HasUnevenRows = true, + ItemTemplate = new DataTemplate(() => new VaultAttachmentsViewCell()), + Footer = NewTable, + VerticalOptions = LayoutOptions.FillAndExpand + }; + + NoDataLabel = new Label + { + Text = AppResources.NoAttachments, + HorizontalTextAlignment = TextAlignment.Center, + FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)), + Style = (Style)Application.Current.Resources["text-muted"] + }; + + NoDataStackLayout = new StackLayout + { + VerticalOptions = LayoutOptions.Start, + Spacing = 0, + Margin = new Thickness(0, 40, 0, 0) + }; + + var saveToolBarItem = new ToolbarItem(AppResources.Save, null, async () => + { + if(_lastAction.LastActionWasRecent() || _login == null) + { + return; + } + _lastAction = DateTime.UtcNow; + + if(!_connectivity.IsConnected) + { + AlertNoConnection(); + return; + } + + if(_fileBytes == null) + { + await DisplayAlert(AppResources.AnErrorHasOccurred, string.Format(AppResources.ValidationFieldRequired, + AppResources.File), AppResources.Ok); + return; + } + + _userDialogs.ShowLoading(AppResources.Saving, MaskType.Black); + var saveTask = await _loginService.EncryptAndSaveAttachmentAsync(_login, _fileBytes, FileLabel.Text); + + _userDialogs.HideLoading(); + + if(saveTask.Succeeded) + { + _fileBytes = null; + FileLabel.Text = AppResources.NoFileChosen; + _userDialogs.Toast(AppResources.AttachementAdded); + _googleAnalyticsService.TrackAppEvent("AddedAttachment"); + await LoadAttachmentsAsync(); + } + else if(saveTask.Errors.Count() > 0) + { + await _userDialogs.AlertAsync(saveTask.Errors.First().Message, AppResources.AnErrorHasOccurred); + } + else + { + await _userDialogs.AlertAsync(AppResources.AnErrorHasOccurred); + } + }, ToolbarItemOrder.Default, 0); + + Title = AppResources.Attachments; + Content = ListView; + ToolbarItems.Add(saveToolBarItem); + + if(Device.RuntimePlatform == Device.iOS) + { + ListView.RowHeight = -1; + NewTable.RowHeight = -1; + NewTable.EstimatedRowHeight = 44; + NewTable.HeightRequest = 180; + ListView.BackgroundColor = Color.Transparent; + ToolbarItems.Add(new DismissModalToolBarItem(this, AppResources.Close)); + } + } + + protected async override void OnAppearing() + { + base.OnAppearing(); + ListView.ItemSelected += AttachmentSelected; + await LoadAttachmentsAsync(); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + ListView.ItemSelected -= AttachmentSelected; + } + + private async Task LoadAttachmentsAsync() + { + _login = await _loginService.GetByIdAsync(_loginId); + if(_login == null) + { + await Navigation.PopForDeviceAsync(); + return; + } + + var attachmentsToAdd = _login.Attachments + .Select(a => new VaultAttachmentsPageModel.Attachment(a)) + .OrderBy(s => s.Name); + PresentationAttchments.ResetWithRange(attachmentsToAdd); + AdjustContent(); + } + + private void AdjustContent() + { + if(PresentationAttchments.Count == 0) + { + NoDataStackLayout.Children.Clear(); + NoDataStackLayout.Children.Add(NoDataLabel); + NoDataStackLayout.Children.Add(NewTable); + Content = NoDataStackLayout; + } + else + { + Content = ListView; + } + } + + private async void AttachmentSelected(object sender, SelectedItemChangedEventArgs e) + { + var attachment = e.SelectedItem as VaultAttachmentsPageModel.Attachment; + if(attachment == null) + { + return; + } + + ((ListView)sender).SelectedItem = null; + + var buttons = new List { }; + var selection = await DisplayActionSheet(attachment.Name, AppResources.Cancel, AppResources.Delete, + buttons.ToArray()); + + if(selection == AppResources.Delete) + { + _userDialogs.ShowLoading(AppResources.Deleting, MaskType.Black); + var saveTask = await _loginService.DeleteAttachmentAsync(_login, attachment.Id); + _userDialogs.HideLoading(); + + if(saveTask.Succeeded) + { + _userDialogs.Toast(AppResources.AttachmentDeleted); + _googleAnalyticsService.TrackAppEvent("DeletedAttachment"); + await LoadAttachmentsAsync(); + } + else if(saveTask.Errors.Count() > 0) + { + await _userDialogs.AlertAsync(saveTask.Errors.First().Message, AppResources.AnErrorHasOccurred); + } + else + { + await _userDialogs.AlertAsync(AppResources.AnErrorHasOccurred); + } + } + } + + private void AlertNoConnection() + { + DisplayAlert(AppResources.InternetConnectionRequiredTitle, AppResources.InternetConnectionRequiredMessage, + AppResources.Ok); + } + + private void SubscribeFileResult(bool subscribe) + { + MessagingCenter.Unsubscribe>(Application.Current, "SelectFileResult"); + if(!subscribe) + { + return; + } + + MessagingCenter.Subscribe>( + Application.Current, "SelectFileResult", (sender, result) => + { + FileLabel.Text = result.Item2; + _fileBytes = result.Item1; + SubscribeFileResult(true); + }); + } + } +} diff --git a/src/App/Pages/Vault/VaultEditLoginPage.cs b/src/App/Pages/Vault/VaultEditLoginPage.cs index 83bce7d6d..f70dc3891 100644 --- a/src/App/Pages/Vault/VaultEditLoginPage.cs +++ b/src/App/Pages/Vault/VaultEditLoginPage.cs @@ -42,6 +42,7 @@ namespace Bit.App.Pages public FormEditorCell NotesCell { get; private set; } public FormPickerCell FolderCell { get; private set; } public ExtendedTextCell GenerateCell { get; private set; } + public ExtendedTextCell AttachmentsCell { get; private set; } public ExtendedTextCell DeleteCell { get; private set; } private void Init() @@ -112,6 +113,12 @@ namespace Bit.App.Pages On = login.Favorite }; + AttachmentsCell = new ExtendedTextCell + { + Text = AppResources.Attachments, + ShowDisclousure = true + }; + DeleteCell = new ExtendedTextCell { Text = AppResources.Delete, TextColor = Color.Red }; var table = new ExtendedTableView @@ -133,7 +140,8 @@ namespace Bit.App.Pages { TotpCell, FolderCell, - favoriteCell + favoriteCell, + AttachmentsCell }, new TableSection(AppResources.Notes) { @@ -257,6 +265,10 @@ namespace Bit.App.Pages { GenerateCell.Tapped += GenerateCell_Tapped; } + if(AttachmentsCell != null) + { + AttachmentsCell.Tapped += AttachmentsCell_Tapped; + } if(DeleteCell != null) { DeleteCell.Tapped += DeleteCell_Tapped; @@ -286,6 +298,10 @@ namespace Bit.App.Pages { GenerateCell.Tapped -= GenerateCell_Tapped; } + if(AttachmentsCell != null) + { + AttachmentsCell.Tapped -= AttachmentsCell_Tapped; + } if(DeleteCell != null) { DeleteCell.Tapped -= DeleteCell_Tapped; @@ -336,6 +352,12 @@ namespace Bit.App.Pages await Navigation.PushForDeviceAsync(page); } + private async void AttachmentsCell_Tapped(object sender, EventArgs e) + { + var page = new ExtendedNavigationPage(new VaultAttachmentsPage(_loginId)); + await Navigation.PushModalAsync(page); + } + private async void DeleteCell_Tapped(object sender, EventArgs e) { if(!_connectivity.IsConnected) diff --git a/src/App/Pages/Vault/VaultViewLoginPage.cs b/src/App/Pages/Vault/VaultViewLoginPage.cs index 73f21da2e..00e60781e 100644 --- a/src/App/Pages/Vault/VaultViewLoginPage.cs +++ b/src/App/Pages/Vault/VaultViewLoginPage.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Bit.App.Utilities; using System.Collections.Generic; using Bit.App.Models; +using System.Linq; namespace Bit.App.Pages { @@ -164,52 +165,77 @@ namespace Bit.App.Pages Model.Update(login); - if(!Model.ShowUri) + if(LoginInformationSection.Contains(UriCell)) { LoginInformationSection.Remove(UriCell); } - else if(!LoginInformationSection.Contains(UriCell)) + if(Model.ShowUri) { LoginInformationSection.Add(UriCell); } - if(!Model.ShowUsername) + if(LoginInformationSection.Contains(UsernameCell)) { LoginInformationSection.Remove(UsernameCell); } - else if(!LoginInformationSection.Contains(UsernameCell)) + if(Model.ShowUsername) { LoginInformationSection.Add(UsernameCell); } - if(!Model.ShowPassword) + if(LoginInformationSection.Contains(PasswordCell)) { LoginInformationSection.Remove(PasswordCell); } - else if(!LoginInformationSection.Contains(PasswordCell)) + if(Model.ShowPassword) { LoginInformationSection.Add(PasswordCell); } - if(!Model.ShowNotes) + if(Table.Root.Contains(NotesSection)) { Table.Root.Remove(NotesSection); } - else if(!Table.Root.Contains(NotesSection)) + if(Model.ShowNotes) { Table.Root.Add(NotesSection); } + // Totp + if(LoginInformationSection.Contains(TotpCodeCell)) + { + LoginInformationSection.Remove(TotpCodeCell); + } + if(login.Totp != null && (_tokenService.TokenPremium || login.OrganizationUseTotp)) + { + var totpKey = login.Totp.Decrypt(login.OrganizationId); + if(!string.IsNullOrWhiteSpace(totpKey)) + { + Model.TotpCode = Crypto.Totp(totpKey); + if(!string.IsNullOrWhiteSpace(Model.TotpCode)) + { + TotpTick(totpKey); + Device.StartTimer(new TimeSpan(0, 0, 1), () => + { + TotpTick(totpKey); + return true; + }); + + LoginInformationSection.Add(TotpCodeCell); + } + } + } + CleanupAttachmentCells(); - if(!Model.ShowAttachments && Table.Root.Contains(AttachmentsSection)) + if(Table.Root.Contains(AttachmentsSection)) { Table.Root.Remove(AttachmentsSection); } - else if(Model.ShowAttachments && !Table.Root.Contains(AttachmentsSection)) + if(Model.ShowAttachments) { AttachmentsSection = new TableSection(AppResources.Attachments); AttachmentCells = new List(); - foreach(var attachment in Model.Attachments) + foreach(var attachment in Model.Attachments.OrderBy(s => s.Name)) { var attachmentCell = new AttachmentViewCell(attachment, async () => { @@ -222,38 +248,6 @@ namespace Bit.App.Pages Table.Root.Add(AttachmentsSection); } - // Totp - var removeTotp = login.Totp == null || (!_tokenService.TokenPremium && !login.OrganizationUseTotp); - if(!removeTotp) - { - var totpKey = login.Totp.Decrypt(login.OrganizationId); - removeTotp = string.IsNullOrWhiteSpace(totpKey); - if(!removeTotp) - { - Model.TotpCode = Crypto.Totp(totpKey); - removeTotp = string.IsNullOrWhiteSpace(Model.TotpCode); - if(!removeTotp) - { - TotpTick(totpKey); - Device.StartTimer(new TimeSpan(0, 0, 1), () => - { - TotpTick(totpKey); - return true; - }); - - if(!LoginInformationSection.Contains(TotpCodeCell)) - { - LoginInformationSection.Add(TotpCodeCell); - } - } - } - } - - if(removeTotp && LoginInformationSection.Contains(TotpCodeCell)) - { - LoginInformationSection.Remove(TotpCodeCell); - } - base.OnAppearing(); } diff --git a/src/App/Repositories/CipherApiRepository.cs b/src/App/Repositories/CipherApiRepository.cs index cc8f6c48c..d182d2c88 100644 --- a/src/App/Repositories/CipherApiRepository.cs +++ b/src/App/Repositories/CipherApiRepository.cs @@ -5,6 +5,8 @@ using Bit.App.Abstractions; using Bit.App.Models.Api; using Newtonsoft.Json; using Plugin.Connectivity.Abstractions; +using System.Globalization; +using System.IO; namespace Bit.App.Repositories { @@ -99,5 +101,89 @@ namespace Bit.App.Repositories } } } + + public virtual async Task> PostAttachmentAsync(string cipherId, byte[] data, + string fileName) + { + if(!Connectivity.IsConnected) + { + return HandledNotConnected(); + } + + var tokenStateResponse = await HandleTokenStateAsync(); + if(!tokenStateResponse.Succeeded) + { + return tokenStateResponse; + } + + using(var client = HttpService.ApiClient) + using(var content = new MultipartFormDataContent("--BWMobileFormBoundary" + DateTime.UtcNow.Ticks)) + { + content.Add(new StreamContent(new MemoryStream(data)), "data", fileName); + + var requestMessage = new TokenHttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(client.BaseAddress, string.Concat(ApiRoute, "/", cipherId, "/attachment")), + Content = content + }; + + try + { + var response = await client.SendAsync(requestMessage).ConfigureAwait(false); + if(!response.IsSuccessStatusCode) + { + return await HandleErrorAsync(response).ConfigureAwait(false); + } + + var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var responseObj = JsonConvert.DeserializeObject(responseContent); + return ApiResult.Success(responseObj, response.StatusCode); + } + catch + { + return HandledWebException(); + } + } + } + + public virtual async Task DeleteAttachmentAsync(string cipherId, string attachmentId) + { + if(!Connectivity.IsConnected) + { + return HandledNotConnected(); + } + + var tokenStateResponse = await HandleTokenStateAsync(); + if(!tokenStateResponse.Succeeded) + { + return tokenStateResponse; + } + + using(var client = HttpService.ApiClient) + { + var requestMessage = new TokenHttpRequestMessage() + { + Method = HttpMethod.Delete, + RequestUri = new Uri(client.BaseAddress, + string.Concat(ApiRoute, "/", cipherId, "/attachment/", attachmentId)), + }; + + try + { + var response = await client.SendAsync(requestMessage).ConfigureAwait(false); + if(!response.IsSuccessStatusCode) + { + return await HandleErrorAsync(response).ConfigureAwait(false); + } + + return ApiResult.Success(response.StatusCode); + } + catch + { + return HandledWebException(); + } + } + } } } diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index 927c9ec36..99a378977 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -151,6 +151,24 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Attachment added. + /// + public static string AttachementAdded { + get { + return ResourceManager.GetString("AttachementAdded", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attachment deleted. + /// + public static string AttachmentDeleted { + get { + return ResourceManager.GetString("AttachmentDeleted", resourceCulture); + } + } + /// /// Looks up a localized string similar to This attachment is {0} in size. Are you sure you want to download it onto your device?. /// @@ -529,6 +547,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Choose File. + /// + public static string ChooseFile { + get { + return ResourceManager.GetString("ChooseFile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close. /// @@ -997,6 +1024,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to File. + /// + public static string File { + get { + return ResourceManager.GetString("File", resourceCulture); + } + } + /// /// Looks up a localized string similar to File a Bug Report. /// @@ -1582,6 +1618,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to There are no attachments.. + /// + public static string NoAttachments { + get { + return ResourceManager.GetString("NoAttachments", resourceCulture); + } + } + /// /// Looks up a localized string similar to There are no favorites in your vault.. /// @@ -1591,6 +1636,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to No file chosen. + /// + public static string NoFileChosen { + get { + return ResourceManager.GetString("NoFileChosen", resourceCulture); + } + } + /// /// Looks up a localized string similar to There are no logins in your vault.. /// diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index da129b2a7..075f076ba 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -965,4 +965,22 @@ A premium membership is required to use this feature. + + Attachment added + + + Attachment deleted + + + Choose File + + + File + + + No file chosen + + + There are no attachments. + \ No newline at end of file diff --git a/src/App/Services/CryptoService.cs b/src/App/Services/CryptoService.cs index 4483b481c..d0f246da2 100644 --- a/src/App/Services/CryptoService.cs +++ b/src/App/Services/CryptoService.cs @@ -260,6 +260,26 @@ namespace Bit.App.Services return Crypto.AesCbcEncrypt(plainBytes, key); } + public byte[] EncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key = null) + { + if(key == null) + { + key = EncKey ?? Key; + } + + if(key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if(plainBytes == null) + { + throw new ArgumentNullException(nameof(plainBytes)); + } + + return Crypto.AesCbcEncryptToBytes(plainBytes, key); + } + public string Decrypt(CipherString encyptedValue, SymmetricCryptoKey key = null) { try diff --git a/src/App/Services/LoginService.cs b/src/App/Services/LoginService.cs index c439e556b..fe6a2f91a 100644 --- a/src/App/Services/LoginService.cs +++ b/src/App/Services/LoginService.cs @@ -17,6 +17,7 @@ namespace Bit.App.Services private readonly IAttachmentRepository _attachmentRepository; private readonly IAuthService _authService; private readonly ILoginApiRepository _loginApiRepository; + private readonly ICipherApiRepository _cipherApiRepository; private readonly ISettingsService _settingsService; private readonly ICryptoService _cryptoService; @@ -25,6 +26,7 @@ namespace Bit.App.Services IAttachmentRepository attachmentRepository, IAuthService authService, ILoginApiRepository loginApiRepository, + ICipherApiRepository cipherApiRepository, ISettingsService settingsService, ICryptoService cryptoService) { @@ -32,6 +34,7 @@ namespace Bit.App.Services _attachmentRepository = attachmentRepository; _authService = authService; _loginApiRepository = loginApiRepository; + _cipherApiRepository = cipherApiRepository; _settingsService = settingsService; _cryptoService = cryptoService; } @@ -238,7 +241,7 @@ namespace Bit.App.Services { return null; } - + if(!string.IsNullOrWhiteSpace(orgId)) { return _cryptoService.DecryptToBytes(data, _cryptoService.GetOrgKey(orgId)); @@ -255,6 +258,47 @@ namespace Bit.App.Services } } + public async Task> EncryptAndSaveAttachmentAsync(Login login, byte[] data, string fileName) + { + var encFileName = fileName.Encrypt(login.OrganizationId); + var encBytes = _cryptoService.EncryptToBytes(data, + login.OrganizationId != null ? _cryptoService.GetOrgKey(login.OrganizationId) : null); + var response = await _cipherApiRepository.PostAttachmentAsync(login.Id, encBytes, encFileName.EncryptedString); + + if(response.Succeeded) + { + var attachmentData = response.Result.Attachments.Select(a => new AttachmentData(a, login.Id)); + foreach(var attachment in attachmentData) + { + await _attachmentRepository.UpsertAsync(attachment); + } + login.Attachments = response.Result.Attachments.Select(a => new Attachment(a)); + } + else if(response.StatusCode == System.Net.HttpStatusCode.Forbidden + || response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + MessagingCenter.Send(Application.Current, "Logout", (string)null); + } + + return response; + } + + public async Task DeleteAttachmentAsync(Login login, string attachmentId) + { + var response = await _cipherApiRepository.DeleteAttachmentAsync(login.Id, attachmentId); + if(response.Succeeded) + { + await _attachmentRepository.DeleteAsync(attachmentId); + } + else if(response.StatusCode == System.Net.HttpStatusCode.Forbidden + || response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + MessagingCenter.Send(Application.Current, "Logout", (string)null); + } + + return response; + } + private string WebUriFromAndroidAppUri(string androidAppUriString) { if(!UriIsAndroidApp(androidAppUriString)) diff --git a/src/App/Utilities/Crypto.cs b/src/App/Utilities/Crypto.cs index 20abfda3e..dd3329aa9 100644 --- a/src/App/Utilities/Crypto.cs +++ b/src/App/Utilities/Crypto.cs @@ -10,6 +10,26 @@ namespace Bit.App.Utilities public static class Crypto { public static CipherString AesCbcEncrypt(byte[] plainBytes, SymmetricCryptoKey key) + { + var parts = AesCbcEncryptToParts(plainBytes, key); + return new CipherString(parts.Item1, Convert.ToBase64String(parts.Item2), + Convert.ToBase64String(parts.Item4), Convert.ToBase64String(parts.Item3)); + } + + public static byte[] AesCbcEncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key) + { + var parts = AesCbcEncryptToParts(plainBytes, key); + + var encBytes = new byte[1 + parts.Item2.Length + parts.Item3.Length + parts.Item4.Length]; + encBytes[0] = (byte)parts.Item1; + parts.Item2.CopyTo(encBytes, 1); + parts.Item3.CopyTo(encBytes, 1 + parts.Item2.Length); + parts.Item4.CopyTo(encBytes, 1 + parts.Item2.Length + parts.Item3.Length); + return encBytes; + } + + private static Tuple AesCbcEncryptToParts(byte[] plainBytes, + SymmetricCryptoKey key) { if(key == null) { @@ -24,11 +44,10 @@ namespace Bit.App.Utilities var provider = WinRTCrypto.SymmetricKeyAlgorithmProvider.OpenAlgorithm(SymmetricAlgorithm.AesCbcPkcs7); var cryptoKey = provider.CreateSymmetricKey(key.EncKey); var iv = RandomBytes(provider.BlockLength); - var encryptedBytes = WinRTCrypto.CryptographicEngine.Encrypt(cryptoKey, plainBytes, iv); - var mac = key.MacKey != null ? ComputeMacBase64(encryptedBytes, iv, key.MacKey) : null; + var ct = WinRTCrypto.CryptographicEngine.Encrypt(cryptoKey, plainBytes, iv); + var mac = key.MacKey != null ? ComputeMac(ct, iv, key.MacKey) : null; - return new CipherString(key.EncryptionType, Convert.ToBase64String(iv), - Convert.ToBase64String(encryptedBytes), mac); + return new Tuple(key.EncryptionType, iv, mac, ct); } public static byte[] AesCbcDecrypt(CipherString encyptedValue, SymmetricCryptoKey key) @@ -84,12 +103,6 @@ namespace Bit.App.Utilities return WinRTCrypto.CryptographicBuffer.GenerateRandom(length); } - public static string ComputeMacBase64(byte[] ctBytes, byte[] ivBytes, byte[] macKey) - { - var mac = ComputeMac(ctBytes, ivBytes, macKey); - return Convert.ToBase64String(mac); - } - public static byte[] ComputeMac(byte[] ctBytes, byte[] ivBytes, byte[] macKey) { if(ctBytes == null) diff --git a/src/iOS/Services/DeviceActionService.cs b/src/iOS/Services/DeviceActionService.cs index 798c7c21c..560cf8974 100644 --- a/src/iOS/Services/DeviceActionService.cs +++ b/src/iOS/Services/DeviceActionService.cs @@ -5,6 +5,9 @@ using Foundation; using System.IO; using MobileCoreServices; using Bit.App.Resources; +using Xamarin.Forms; +using Photos; +using System.Net; namespace Bit.iOS.Services { @@ -86,7 +89,7 @@ namespace Bit.iOS.Services return tmp; } - public byte[] SelectFile() + public void SelectFile() { var controller = GetVisibleViewController(); var picker = new UIDocumentMenuViewController(new string[] { UTType.Data }, UIDocumentPickerMode.Import); @@ -114,18 +117,25 @@ namespace Bit.iOS.Services }; controller.PresentViewController(picker, true, null); - return null; } private void ImagePicker_FinishedPickingMedia(object sender, UIImagePickerMediaPickedEventArgs e) { if(sender is UIImagePickerController picker) { - //var image = (UIImage)e.Info.ObjectForKey(new NSString("UIImagePickerControllerOriginalImage")); + string fileName = null; + NSObject urlObj; + if(e.Info.TryGetValue(UIImagePickerController.ReferenceUrl, out urlObj)) + { + var result = PHAsset.FetchAssets(new NSUrl[] { (urlObj as NSUrl) }, null); + fileName = result?.firstObject?.ValueForKey(new NSString("filename"))?.ToString(); + } - // TODO: determine if JPG or PNG from extension. Get filename somehow? + fileName = fileName ?? $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg"; + + var lowerFilename = fileName?.ToLowerInvariant(); byte[] data; - if(false) + if(lowerFilename != null && (lowerFilename.EndsWith(".jpg") || lowerFilename.EndsWith(".jpeg"))) { using(var imageData = e.OriginalImage.AsJPEG()) { @@ -144,6 +154,7 @@ namespace Bit.iOS.Services } } + SelectFileResult(data, fileName); picker.DismissViewController(true, null); } } @@ -159,17 +170,35 @@ namespace Bit.iOS.Services private void DocumentPicker_DidPickDocument(object sender, UIDocumentPickedEventArgs e) { e.Url.StartAccessingSecurityScopedResource(); + + var doc = new UIDocument(e.Url); + var fileName = doc.LocalizedName; + if(string.IsNullOrWhiteSpace(fileName)) + { + var path = doc.FileUrl?.ToString(); + if(path != null) + { + path = WebUtility.UrlDecode(path); + var split = path.LastIndexOf('/'); + fileName = path.Substring(split + 1); + } + } + var fileCoordinator = new NSFileCoordinator(); - - // TODO: get filename? - NSError error; fileCoordinator.CoordinateRead(e.Url, NSFileCoordinatorReadingOptions.WithoutChanges, out error, (url) => { var data = NSData.FromUrl(url).ToArray(); + SelectFileResult(data, fileName ?? "unknown_file_name"); }); e.Url.StopAccessingSecurityScopedResource(); } + + private void SelectFileResult(byte[] data, string fileName) + { + MessagingCenter.Send(Xamarin.Forms.Application.Current, "SelectFileResult", + new Tuple(data, fileName)); + } } } diff --git a/test/Android.Test/Resources/Resource.Designer.cs b/test/Android.Test/Resources/Resource.Designer.cs index acc18d34d..38fc5df5d 100644 --- a/test/Android.Test/Resources/Resource.Designer.cs +++ b/test/Android.Test/Resources/Resource.Designer.cs @@ -2170,6 +2170,9 @@ namespace Bit.Android.Test // aapt resource value: 0x7f080038 public const int collapseActionView = 2131230776; + // aapt resource value: 0x7f0800b7 + public const int contentFrame = 2131230903; + // aapt resource value: 0x7f08004c public const int contentPanel = 2131230796; @@ -2790,6 +2793,12 @@ namespace Bit.Android.Test // aapt resource value: 0x7f03003e public const int test_suite = 2130903102; + // aapt resource value: 0x7f03003f + public const int zxingscanneractivitylayout = 2130903103; + + // aapt resource value: 0x7f030040 + public const int zxingscannerfragmentlayout = 2130903104; + static Layout() { global::Android.Runtime.ResourceIdManager.UpdateIdValues();