From 29b37219c246d25e374f106c214758cace9fc406 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 10 May 2019 23:43:35 -0400 Subject: [PATCH] attachments page --- src/Android/Android.csproj | 1 + src/Android/MainActivity.cs | 71 ++++++++- src/Android/MainApplication.cs | 3 +- src/Android/Services/DeviceActionService.cs | 106 +++++++++++++- src/Android/Utilities/AndroidHelpers.cs | 30 ++++ src/App/Abstractions/IDeviceActionService.cs | 1 + src/App/Pages/Vault/AddEditPage.xaml.cs | 5 +- src/App/Pages/Vault/AttachmentsPage.xaml | 60 ++++++-- src/App/Pages/Vault/AttachmentsPage.xaml.cs | 29 +++- .../Pages/Vault/AttachmentsPageViewModel.cs | 138 ++++++++++++++---- src/App/Pages/Vault/SharePageViewModel.cs | 5 + src/App/Pages/Vault/ViewPage.xaml.cs | 5 +- src/Core/Constants.cs | 2 + src/Core/Services/ApiService.cs | 2 +- src/Core/Services/CipherService.cs | 45 ++---- src/iOS/Services/DeviceActionService.cs | 123 +++++++++++++++- 16 files changed, 536 insertions(+), 90 deletions(-) create mode 100644 src/Android/Utilities/AndroidHelpers.cs diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index 2b4256edc..7725cb088 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -100,6 +100,7 @@ + diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index 38f1506bb..fba508cb6 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -2,6 +2,15 @@ using Android.Content.PM; using Android.Runtime; using Android.OS; +using Bit.Core; +using System.Linq; +using Bit.App.Abstractions; +using Bit.Core.Utilities; +using Bit.Core.Abstractions; +using System.IO; +using System; +using Android.Content; +using Bit.Droid.Utilities; namespace Bit.Droid { @@ -14,8 +23,14 @@ namespace Bit.Droid [Register("com.x8bit.bitwarden.MainActivity")] public class MainActivity : Xamarin.Forms.Platform.Android.FormsAppCompatActivity { + private IDeviceActionService _deviceActionService; + private IMessagingService _messagingService; + protected override void OnCreate(Bundle savedInstanceState) { + _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _messagingService = ServiceContainer.Resolve("messagingService"); + TabLayoutResource = Resource.Layout.Tabbar; ToolbarResource = Resource.Layout.Toolbar; @@ -25,11 +40,63 @@ namespace Bit.Droid LoadApplication(new App.App()); } - public override void OnRequestPermissionsResult(int requestCode, string[] permissions, + public async override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Permission[] grantResults) { - Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); + if(requestCode == Constants.SelectFilePermissionRequestCode) + { + if(grantResults.Any(r => r != Permission.Granted)) + { + _messagingService.Send("selectFileCameraPermissionDenied"); + } + await _deviceActionService.SelectFileAsync(); + } + else + { + Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); + } base.OnRequestPermissionsResult(requestCode, permissions, grantResults); } + + protected override void OnActivityResult(int requestCode, Result resultCode, Intent data) + { + if(requestCode == Constants.SelectFileRequestCode && resultCode == Result.Ok) + { + Android.Net.Uri uri = null; + string fileName = null; + if(data != null && data.Data != null) + { + uri = data.Data; + fileName = AndroidHelpers.GetFileName(ApplicationContext, uri); + } + else + { + // camera + var root = new Java.IO.File(Android.OS.Environment.ExternalStorageDirectory, "bitwarden"); + var file = new Java.IO.File(root, "temp_camera_photo.jpg"); + uri = Android.Net.Uri.FromFile(file); + fileName = $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg"; + } + + if(uri == null) + { + return; + } + try + { + using(var stream = ContentResolver.OpenInputStream(uri)) + using(var memoryStream = new MemoryStream()) + { + stream.CopyTo(memoryStream); + _messagingService.Send("selectFileResult", + new Tuple(memoryStream.ToArray(), fileName ?? "unknown_file_name")); + } + } + catch(Java.IO.FileNotFoundException) + { + return; + } + } + } } } diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index df6568a51..fab8d5a82 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -54,7 +54,8 @@ namespace Bit.Droid var secureStorageService = new SecureStorageService(); var cryptoPrimitiveService = new CryptoPrimitiveService(); var mobileStorageService = new MobileStorageService(preferencesStorage, liteDbStorage); - var deviceActionService = new DeviceActionService(mobileStorageService); + var deviceActionService = new DeviceActionService(mobileStorageService, messagingService, + broadcasterService); var platformUtilsService = new MobilePlatformUtilsService(deviceActionService, messagingService, broadcasterService); diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index 49b74f50d..47d1b6bef 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -1,9 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Android; using Android.App; using Android.Content; using Android.Content.PM; +using Android.OS; +using Android.Provider; +using Android.Support.V4.App; using Android.Support.V4.Content; using Android.Webkit; using Android.Widget; @@ -19,13 +24,28 @@ namespace Bit.Droid.Services public class DeviceActionService : IDeviceActionService { private readonly IStorageService _storageService; - + private readonly IMessagingService _messagingService; + private readonly IBroadcasterService _broadcasterService; private ProgressDialog _progressDialog; - private Android.Widget.Toast _toast; + private bool _cameraPermissionsDenied; + private Toast _toast; - public DeviceActionService(IStorageService storageService) + public DeviceActionService( + IStorageService storageService, + IMessagingService messagingService, + IBroadcasterService broadcasterService) { _storageService = storageService; + _messagingService = messagingService; + _broadcasterService = broadcasterService; + + _broadcasterService.Subscribe(nameof(DeviceActionService), (message) => + { + if(message.Command == "selectFileCameraPermissionDenied") + { + _cameraPermissionsDenied = true; + } + }); } public DeviceType DeviceType => DeviceType.Android; @@ -39,7 +59,7 @@ namespace Bit.Droid.Services _toast = null; } _toast = Android.Widget.Toast.MakeText(CrossCurrentActivity.Current.Activity, text, - longDuration ? Android.Widget.ToastLength.Long : Android.Widget.ToastLength.Short); + longDuration ? ToastLength.Long : ToastLength.Short); _toast.Show(); } @@ -149,6 +169,54 @@ namespace Bit.Droid.Services catch(Exception) { } } + public Task SelectFileAsync() + { + var activity = (MainActivity)CrossCurrentActivity.Current.Activity; + var hasStorageWritePermission = !_cameraPermissionsDenied && HasPermission(Manifest.Permission.WriteExternalStorage); + var additionalIntents = new List(); + if(activity.PackageManager.HasSystemFeature(PackageManager.FeatureCamera)) + { + var hasCameraPermission = !_cameraPermissionsDenied && HasPermission(Manifest.Permission.Camera); + if(!_cameraPermissionsDenied && !hasStorageWritePermission) + { + AskPermission(Manifest.Permission.WriteExternalStorage); + return Task.FromResult(0); + } + if(!_cameraPermissionsDenied && !hasCameraPermission) + { + AskPermission(Manifest.Permission.Camera); + return Task.FromResult(0); + } + if(!_cameraPermissionsDenied && hasCameraPermission && hasStorageWritePermission) + { + try + { + var root = new Java.IO.File(Android.OS.Environment.ExternalStorageDirectory, "bitwarden"); + var file = new Java.IO.File(root, "temp_camera_photo.jpg"); + if(!file.Exists()) + { + file.ParentFile.Mkdirs(); + file.CreateNewFile(); + } + var outputFileUri = Android.Net.Uri.FromFile(file); + additionalIntents.AddRange(GetCameraIntents(outputFileUri)); + } + catch(Java.IO.IOException) { } + } + } + + var docIntent = new Intent(Intent.ActionOpenDocument); + docIntent.AddCategory(Intent.CategoryOpenable); + docIntent.SetType("*/*"); + var chooserIntent = Intent.CreateChooser(docIntent, AppResources.FileSource); + if(additionalIntents.Count > 0) + { + chooserIntent.PutExtra(Intent.ExtraInitialIntents, additionalIntents.ToArray()); + } + activity.StartActivityForResult(chooserIntent, Constants.SelectFileRequestCode); + return Task.FromResult(0); + } + public Task DisplayPromptAync(string title = null, string description = null, string text = null, string okButtonText = null, string cancelButtonText = null) { @@ -217,5 +285,35 @@ namespace Bit.Droid.Services return false; } } + + private bool HasPermission(string permission) + { + return ContextCompat.CheckSelfPermission( + CrossCurrentActivity.Current.Activity, permission) == Permission.Granted; + } + + private void AskPermission(string permission) + { + ActivityCompat.RequestPermissions(CrossCurrentActivity.Current.Activity, new string[] { permission }, + Constants.SelectFilePermissionRequestCode); + } + + private List GetCameraIntents(Android.Net.Uri outputUri) + { + var intents = new List(); + var pm = CrossCurrentActivity.Current.Activity.PackageManager; + var captureIntent = new Intent(MediaStore.ActionImageCapture); + var listCam = pm.QueryIntentActivities(captureIntent, 0); + foreach(var res in listCam) + { + var packageName = res.ActivityInfo.PackageName; + var intent = new Intent(captureIntent); + intent.SetComponent(new ComponentName(packageName, res.ActivityInfo.Name)); + intent.SetPackage(packageName); + intent.PutExtra(MediaStore.ExtraOutput, outputUri); + intents.Add(intent); + } + return intents; + } } } \ No newline at end of file diff --git a/src/Android/Utilities/AndroidHelpers.cs b/src/Android/Utilities/AndroidHelpers.cs new file mode 100644 index 000000000..5973ca5e8 --- /dev/null +++ b/src/Android/Utilities/AndroidHelpers.cs @@ -0,0 +1,30 @@ +using Android.Content; +using Android.Provider; + +namespace Bit.Droid.Utilities +{ + public static class AndroidHelpers + { + public static string GetFileName(Context context, 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; + } + } +} diff --git a/src/App/Abstractions/IDeviceActionService.cs b/src/App/Abstractions/IDeviceActionService.cs index 8df346c4b..2cbfb60d9 100644 --- a/src/App/Abstractions/IDeviceActionService.cs +++ b/src/App/Abstractions/IDeviceActionService.cs @@ -13,6 +13,7 @@ namespace Bit.App.Abstractions bool OpenFile(byte[] fileData, string id, string fileName); bool CanOpenFile(string fileName); Task ClearCacheAsync(); + Task SelectFileAsync(); Task DisplayPromptAync(string title = null, string description = null, string text = null, string okButtonText = null, string cancelButtonText = null); } diff --git a/src/App/Pages/Vault/AddEditPage.xaml.cs b/src/App/Pages/Vault/AddEditPage.xaml.cs index 2b8075d37..e5d483967 100644 --- a/src/App/Pages/Vault/AddEditPage.xaml.cs +++ b/src/App/Pages/Vault/AddEditPage.xaml.cs @@ -99,11 +99,12 @@ namespace Bit.App.Pages _vm.AddField(); } - private void Attachments_Clicked(object sender, System.EventArgs e) + private async void Attachments_Clicked(object sender, System.EventArgs e) { if(DoOnce()) { - // await Navigation.PushModalAsync(); + var page = new AttachmentsPage(_vm.CipherId); + await Navigation.PushModalAsync(new NavigationPage(page)); } } diff --git a/src/App/Pages/Vault/AttachmentsPage.xaml b/src/App/Pages/Vault/AttachmentsPage.xaml index a5f8f752e..d60a9ddbf 100644 --- a/src/App/Pages/Vault/AttachmentsPage.xaml +++ b/src/App/Pages/Vault/AttachmentsPage.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="Bit.App.Pages.AttachmentsPage" xmlns:pages="clr-namespace:Bit.App.Pages" + xmlns:views="clr-namespace:Bit.Core.Models.View;assembly=BitwardenCore" xmlns:u="clr-namespace:Bit.App.Utilities" xmlns:controls="clr-namespace:Bit.App.Controls" x:DataType="pages:AttachmentsPageViewModel" @@ -21,6 +22,7 @@ + @@ -28,22 +30,30 @@ - - + - + - + @@ -51,6 +61,34 @@ + + + + + + + diff --git a/src/App/Pages/Vault/AttachmentsPage.xaml.cs b/src/App/Pages/Vault/AttachmentsPage.xaml.cs index b237375ef..d570611e8 100644 --- a/src/App/Pages/Vault/AttachmentsPage.xaml.cs +++ b/src/App/Pages/Vault/AttachmentsPage.xaml.cs @@ -1,14 +1,19 @@ -using Xamarin.Forms; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using System; +using Xamarin.Forms; namespace Bit.App.Pages { public partial class AttachmentsPage : BaseContentPage { private AttachmentsPageViewModel _vm; + private readonly IBroadcasterService _broadcasterService; public AttachmentsPage(string cipherId) { InitializeComponent(); + _broadcasterService = ServiceContainer.Resolve("broadcasterService"); _vm = BindingContext as AttachmentsPageViewModel; _vm.Page = this; _vm.CipherId = cipherId; @@ -18,20 +23,38 @@ namespace Bit.App.Pages protected override async void OnAppearing() { base.OnAppearing(); - await LoadOnAppearedAsync(_scrollView, true, () => _vm.LoadAsync()); + _broadcasterService.Subscribe(nameof(AttachmentsPage), async (message) => + { + if(message.Command == "selectFileResult") + { + var data = message.Data as Tuple; + _vm.FileData = data.Item1; + _vm.FileName = data.Item2; + } + }); + await LoadOnAppearedAsync(_scrollView, true, () => _vm.InitAsync()); } protected override void OnDisappearing() { base.OnDisappearing(); + _broadcasterService.Unsubscribe(nameof(AttachmentsPage)); } - private async void Save_Clicked(object sender, System.EventArgs e) + private async void Save_Clicked(object sender, EventArgs e) { if(DoOnce()) { await _vm.SubmitAsync(); } } + + private async void ChooseFile_Clicked(object sender, EventArgs e) + { + if(DoOnce()) + { + await _vm.ChooseFileAsync(); + } + } } } diff --git a/src/App/Pages/Vault/AttachmentsPageViewModel.cs b/src/App/Pages/Vault/AttachmentsPageViewModel.cs index 4f5af4672..f7f241786 100644 --- a/src/App/Pages/Vault/AttachmentsPageViewModel.cs +++ b/src/App/Pages/Vault/AttachmentsPageViewModel.cs @@ -8,6 +8,7 @@ using Bit.Core.Utilities; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Xamarin.Forms; namespace Bit.App.Pages { @@ -15,65 +16,103 @@ namespace Bit.App.Pages { private readonly IDeviceActionService _deviceActionService; private readonly ICipherService _cipherService; - private readonly ICollectionService _collectionService; + private readonly ICryptoService _cryptoService; + private readonly IUserService _userService; private readonly IPlatformUtilsService _platformUtilsService; private CipherView _cipher; private Cipher _cipherDomain; - private bool _hasCollections; + private bool _hasAttachments; + private bool _hasUpdatedKey; + private bool _canAccessAttachments; + private string _fileName; public AttachmentsPageViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); _cipherService = ServiceContainer.Resolve("cipherService"); + _cryptoService = ServiceContainer.Resolve("cryptoService"); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); - _collectionService = ServiceContainer.Resolve("collectionService"); - Collections = new ExtendedObservableCollection(); - PageTitle = AppResources.Collections; + _userService = ServiceContainer.Resolve("userService"); + Attachments = new ExtendedObservableCollection(); + DeleteAttachmentCommand = new Command(DeleteAsync); + PageTitle = AppResources.Attachments; } public string CipherId { get; set; } - public ExtendedObservableCollection Collections { get; set; } - public bool HasCollections + public CipherView Cipher { - get => _hasCollections; - set => SetProperty(ref _hasCollections, value); + get => _cipher; + set => SetProperty(ref _cipher, value); } + public ExtendedObservableCollection Attachments { get; set; } + public bool HasAttachments + { + get => _hasAttachments; + set => SetProperty(ref _hasAttachments, value); + } + public string FileName + { + get => _fileName; + set => SetProperty(ref _fileName, value); + } + public byte[] FileData { get; set; } + public Command DeleteAttachmentCommand { get; set; } - public async Task LoadAsync() + public async Task InitAsync() { _cipherDomain = await _cipherService.GetAsync(CipherId); - var collectionIds = _cipherDomain.CollectionIds; - _cipher = await _cipherDomain.DecryptAsync(); - var allCollections = await _collectionService.GetAllDecryptedAsync(); - var collections = allCollections - .Where(c => !c.ReadOnly && c.OrganizationId == _cipher.OrganizationId) - .Select(c => new CollectionViewModel + Cipher = await _cipherDomain.DecryptAsync(); + LoadAttachments(); + _hasUpdatedKey = await _cryptoService.HasEncKeyAsync(); + var canAccessPremium = await _userService.CanAccessPremiumAsync(); + _canAccessAttachments = canAccessPremium || Cipher.OrganizationId != null; + if(!_canAccessAttachments) + { + await _platformUtilsService.ShowDialogAsync(AppResources.PremiumRequired); + } + else if(!_hasUpdatedKey) + { + var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.UpdateKey, + AppResources.FeatureUnavailable, AppResources.LearnMore, AppResources.Cancel); + if(confirmed) { - Collection = c, - Checked = collectionIds.Contains(c.Id) - }).ToList(); - Collections.ResetWithRange(collections); - HasCollections = Collections.Any(); + _platformUtilsService.LaunchUri("https://help.bitwarden.com/article/update-encryption-key/"); + } + } } public async Task SubmitAsync() { - if(!Collections.Any(c => c.Checked)) + if(!_hasUpdatedKey) { - await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.SelectOneCollection, - AppResources.Ok); + await _platformUtilsService.ShowDialogAsync(AppResources.UpdateKey, + AppResources.AnErrorHasOccurred); + return false; + } + if(FileData == null) + { + await _platformUtilsService.ShowDialogAsync( + string.Format(AppResources.ValidationFieldRequired, AppResources.File), + AppResources.AnErrorHasOccurred); + return false; + } + if(FileData.Length > 104857600) // 100 MB + { + await _platformUtilsService.ShowDialogAsync(AppResources.MaxFileSize, + AppResources.AnErrorHasOccurred); return false; } - - _cipherDomain.CollectionIds = new HashSet( - Collections.Where(c => c.Checked).Select(c => c.Collection.Id)); try { await _deviceActionService.ShowLoadingAsync(AppResources.Saving); - await _cipherService.SaveCollectionsWithServerAsync(_cipherDomain); + _cipherDomain = await _cipherService.SaveAttachmentRawWithServerAsync( + _cipherDomain, FileName, FileData); + Cipher = await _cipherDomain.DecryptAsync(); await _deviceActionService.HideLoadingAsync(); - _platformUtilsService.ShowToast("success", null, AppResources.ItemUpdated); - await Page.Navigation.PopModalAsync(); + _platformUtilsService.ShowToast("success", null, AppResources.AttachementAdded); + LoadAttachments(); + FileData = null; + FileName = null; return true; } catch(ApiException e) @@ -83,5 +122,44 @@ namespace Bit.App.Pages } return false; } + + public async Task ChooseFileAsync() + { + await _deviceActionService.SelectFileAsync(); + } + + private async void DeleteAsync(AttachmentView attachment) + { + var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToDelete, + null, AppResources.Yes, AppResources.No); + if(!confirmed) + { + return; + } + try + { + await _deviceActionService.ShowLoadingAsync(AppResources.Deleting); + await _cipherService.DeleteAttachmentWithServerAsync(Cipher.Id, attachment.Id); + await _deviceActionService.HideLoadingAsync(); + _platformUtilsService.ShowToast("success", null, AppResources.AttachmentDeleted); + var attachmentToRemove = Cipher.Attachments.FirstOrDefault(a => a.Id == attachment.Id); + if(attachmentToRemove != null) + { + Cipher.Attachments.Remove(attachmentToRemove); + LoadAttachments(); + } + } + catch(ApiException e) + { + await _deviceActionService.HideLoadingAsync(); + await Page.DisplayAlert(AppResources.AnErrorHasOccurred, e.Error.GetSingleMessage(), AppResources.Ok); + } + } + + private void LoadAttachments() + { + Attachments.ResetWithRange(Cipher.Attachments ?? new List()); + HasAttachments = Cipher.HasAttachments; + } } } diff --git a/src/App/Pages/Vault/SharePageViewModel.cs b/src/App/Pages/Vault/SharePageViewModel.cs index 9d3456be7..9af5eaa4f 100644 --- a/src/App/Pages/Vault/SharePageViewModel.cs +++ b/src/App/Pages/Vault/SharePageViewModel.cs @@ -112,6 +112,11 @@ namespace Bit.App.Pages await _deviceActionService.HideLoadingAsync(); await Page.DisplayAlert(AppResources.AnErrorHasOccurred, e.Error.GetSingleMessage(), AppResources.Ok); } + catch(System.Exception e) + { + await _deviceActionService.HideLoadingAsync(); + await Page.DisplayAlert(AppResources.AnErrorHasOccurred, e.Message, AppResources.Ok); + } return false; } diff --git a/src/App/Pages/Vault/ViewPage.xaml.cs b/src/App/Pages/Vault/ViewPage.xaml.cs index 92930ecf6..d3dbb9cb9 100644 --- a/src/App/Pages/Vault/ViewPage.xaml.cs +++ b/src/App/Pages/Vault/ViewPage.xaml.cs @@ -105,11 +105,12 @@ namespace Bit.App.Pages EditToolbarItem_Clicked(sender, e); } - private void Attachments_Clicked(object sender, System.EventArgs e) + private async void Attachments_Clicked(object sender, System.EventArgs e) { if(DoOnce()) { - // await Navigation.PushModalAsync(); + var page = new AttachmentsPage(_vm.CipherId); + await Navigation.PushModalAsync(new NavigationPage(page)); } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7029bce1b..e09205908 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -12,5 +12,7 @@ public static string LastFileCacheClearKey = "lastFileCacheClear"; public static string AccessibilityAutofillPasswordFieldKey = "accessibilityAutofillPasswordField"; public static string AccessibilityAutofillPersistNotificationKey = "accessibilityAutofillPersistNotification"; + public const int SelectFileRequestCode = 42; + public const int SelectFilePermissionRequestCode = 43; } } diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index f1f29c7e9..3bc42479b 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -249,7 +249,7 @@ namespace Bit.Core.Services public Task DeleteCipherAttachmentAsync(string id, string attachmentId) { return SendAsync(HttpMethod.Delete, - string.Concat("/ciphers/", id, "/attachments/", attachmentId), null, true, false); + string.Concat("/ciphers/", id, "/attachment/", attachmentId), null, true, false); } public Task PostShareCipherAttachmentAsync(string id, string attachmentId, MultipartFormDataContent data, diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index 13628d308..23bd3e451 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -519,22 +519,11 @@ namespace Bit.Core.Services var encFileName = await _cryptoService.EncryptAsync(filename, key); var dataEncKey = await _cryptoService.MakeEncKeyAsync(key); var encData = await _cryptoService.EncryptToBytesAsync(data, dataEncKey.Item1); - - CipherResponse response; - try - { - using(var fd = new MultipartFormDataContent(string.Concat("Upload----", DateTime.UtcNow))) - { - fd.Add(new StreamContent(new MemoryStream(encData)), "data", encFileName.EncryptedString); - fd.Add(new StringContent(string.Empty), "key", dataEncKey.Item2.EncryptedString); - response = await _apiService.PostCipherAttachmentAsync(cipher.Id, fd); - } - } - catch(ApiException e) - { - throw new Exception(e.Error.GetSingleMessage()); - } - + var boundary = string.Concat("--BWMobileFormBoundary", DateTime.UtcNow.Ticks); + var fd = new MultipartFormDataContent(boundary); + fd.Add(new StringContent(dataEncKey.Item2.EncryptedString), "key"); + fd.Add(new StreamContent(new MemoryStream(encData)), "data", encFileName.EncryptedString); + var response = await _apiService.PostCipherAttachmentAsync(cipher.Id, fd); var userId = await _userService.GetUserIdAsync(); var cData = new CipherData(response, userId, cipher.CollectionIds); await UpsertAsync(cData); @@ -670,12 +659,13 @@ namespace Bit.Core.Services try { await _apiService.DeleteCipherAttachmentAsync(id, attachmentId); + await DeleteAttachmentAsync(id, attachmentId); } catch(ApiException e) { - throw new Exception(e.Error.GetSingleMessage()); + await DeleteAttachmentAsync(id, attachmentId); + throw e; } - await DeleteAttachmentAsync(id, attachmentId); } public async Task DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId) @@ -716,20 +706,11 @@ namespace Bit.Core.Services var encFileName = await _cryptoService.EncryptAsync(attachmentView.FileName, key); var dataEncKey = await _cryptoService.MakeEncKeyAsync(key); var encData = await _cryptoService.EncryptToBytesAsync(decBytes, dataEncKey.Item1); - - try - { - using(var fd = new MultipartFormDataContent(string.Concat("Upload----", DateTime.UtcNow))) - { - fd.Add(new StreamContent(new MemoryStream(encData)), "data", encFileName.EncryptedString); - fd.Add(new StringContent(string.Empty), "key", dataEncKey.Item2.EncryptedString); - await _apiService.PostShareCipherAttachmentAsync(cipherId, attachmentView.Id, fd, organizationId); - } - } - catch(ApiException e) - { - throw new Exception(e.Error.GetSingleMessage()); - } + var boundary = string.Concat("--BWMobileFormBoundary", DateTime.UtcNow.Ticks); + var fd = new MultipartFormDataContent(boundary); + fd.Add(new StringContent(dataEncKey.Item2.EncryptedString), "key"); + fd.Add(new StreamContent(new MemoryStream(encData)), "data", encFileName.EncryptedString); + await _apiService.PostShareCipherAttachmentAsync(cipherId, attachmentView.Id, fd, organizationId); } private bool CheckDefaultUriMatch(CipherView cipher, LoginUriView loginUri, diff --git a/src/iOS/Services/DeviceActionService.cs b/src/iOS/Services/DeviceActionService.cs index c01166393..67ba1c9df 100644 --- a/src/iOS/Services/DeviceActionService.cs +++ b/src/iOS/Services/DeviceActionService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Text; using System.Threading.Tasks; using Bit.App.Abstractions; @@ -12,6 +13,8 @@ using Bit.Core.Enums; using Bit.iOS.Core.Views; using CoreGraphics; using Foundation; +using MobileCoreServices; +using Photos; using UIKit; namespace Bit.iOS.Services @@ -19,13 +22,16 @@ namespace Bit.iOS.Services public class DeviceActionService : IDeviceActionService { private readonly IStorageService _storageService; - + private readonly IMessagingService _messagingService; private Toast _toast; private UIAlertController _progressAlert; - public DeviceActionService(IStorageService storageService) + public DeviceActionService( + IStorageService storageService, + IMessagingService messagingService) { _storageService = storageService; + _messagingService = messagingService; } public DeviceType DeviceType => DeviceType.iOS; @@ -126,6 +132,45 @@ namespace Bit.iOS.Services await _storageService.SaveAsync(Constants.LastFileCacheClearKey, DateTime.UtcNow); } + public Task SelectFileAsync() + { + var controller = GetVisibleViewController(); + var picker = new UIDocumentMenuViewController(new string[] { UTType.Data }, UIDocumentPickerMode.Import); + picker.AddOption(AppResources.Camera, UIImage.FromBundle("camera"), UIDocumentMenuOrder.First, () => + { + var imagePicker = new UIImagePickerController + { + SourceType = UIImagePickerControllerSourceType.Camera + }; + imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; + imagePicker.Canceled += ImagePicker_Canceled; + controller.PresentModalViewController(imagePicker, true); + }); + picker.AddOption(AppResources.Photos, UIImage.FromBundle("photo"), UIDocumentMenuOrder.First, () => + { + var imagePicker = new UIImagePickerController + { + SourceType = UIImagePickerControllerSourceType.PhotoLibrary + }; + imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; + imagePicker.Canceled += ImagePicker_Canceled; + controller.PresentModalViewController(imagePicker, true); + }); + picker.DidPickDocumentPicker += (sender, e) => + { + controller.PresentViewController(e.DocumentPicker, true, null); + e.DocumentPicker.DidPickDocument += DocumentPicker_DidPickDocument; + }; + var root = UIApplication.SharedApplication.KeyWindow.RootViewController; + if(picker.PopoverPresentationController != null && root != null) + { + picker.PopoverPresentationController.SourceView = root.View; + picker.PopoverPresentationController.SourceRect = root.View.Bounds; + } + controller.PresentViewController(picker, true, null); + return Task.FromResult(0); + } + public Task DisplayPromptAync(string title = null, string description = null, string text = null, string okButtonText = null, string cancelButtonText = null) { @@ -152,6 +197,80 @@ namespace Bit.iOS.Services return result.Task; } + private void ImagePicker_FinishedPickingMedia(object sender, UIImagePickerMediaPickedEventArgs e) + { + if(sender is UIImagePickerController picker) + { + string fileName = null; + if(e.Info.TryGetValue(UIImagePickerController.ReferenceUrl, out NSObject urlObj)) + { + var result = PHAsset.FetchAssets(new NSUrl[] { (urlObj as NSUrl) }, null); + fileName = result?.firstObject?.ValueForKey(new NSString("filename"))?.ToString(); + } + fileName = fileName ?? $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg"; + var lowerFilename = fileName?.ToLowerInvariant(); + byte[] data; + if(lowerFilename != null && (lowerFilename.EndsWith(".jpg") || lowerFilename.EndsWith(".jpeg"))) + { + using(var imageData = e.OriginalImage.AsJPEG()) + { + data = new byte[imageData.Length]; + System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, + Convert.ToInt32(imageData.Length)); + } + } + else + { + using(var imageData = e.OriginalImage.AsPNG()) + { + data = new byte[imageData.Length]; + System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, + Convert.ToInt32(imageData.Length)); + } + } + SelectFileResult(data, fileName); + picker.DismissViewController(true, null); + } + } + + private void ImagePicker_Canceled(object sender, EventArgs e) + { + if(sender is UIImagePickerController picker) + { + picker.DismissViewController(true, null); + } + } + + 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(); + fileCoordinator.CoordinateRead(e.Url, NSFileCoordinatorReadingOptions.WithoutChanges, + out NSError error, (url) => + { + var data = NSData.FromUrl(url).ToArray(); + SelectFileResult(data, fileName ?? "unknown_file_name"); + }); + e.Url.StopAccessingSecurityScopedResource(); + } + + private void SelectFileResult(byte[] data, string fileName) + { + _messagingService.Send("selectFileResult", new Tuple(data, fileName)); + } + private UIViewController GetVisibleViewController(UIViewController controller = null) { controller = controller ?? UIApplication.SharedApplication.KeyWindow.RootViewController;