From 1f4bdb04ee320fd834a62aef7cddb104a4c0ae64 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 29 Apr 2019 16:09:27 -0400 Subject: [PATCH] attachments on view page abd device actions --- src/Android/MainApplication.cs | 2 +- src/Android/Properties/AndroidManifest.xml | 9 ++ src/Android/Services/DeviceActionService.cs | 112 ++++++++++++++++++- src/App/Abstractions/IDeviceActionService.cs | 3 + src/App/Pages/Vault/ViewPageViewModel.cs | 39 ++++++- src/Core/Abstractions/ICipherService.cs | 1 + src/Core/Constants.cs | 1 + src/Core/Models/View/AttachmentView.cs | 12 ++ src/Core/Services/CipherService.cs | 21 ++++ src/iOS/Services/DeviceActionService.cs | 68 +++++++++++ 10 files changed, 264 insertions(+), 4 deletions(-) diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index 6c3f2dd3f..89a8cbddf 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -46,7 +46,6 @@ namespace Bit.Droid var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.Personal); var liteDbStorage = new LiteDbStorageService(Path.Combine(documentsPath, "bitwarden.db")); liteDbStorage.InitAsync(); - var deviceActionService = new DeviceActionService(); var localizeService = new LocalizeService(); var broadcasterService = new BroadcasterService(); var messagingService = new MobileBroadcasterMessagingService(broadcasterService); @@ -54,6 +53,7 @@ namespace Bit.Droid var secureStorageService = new SecureStorageService(); var cryptoPrimitiveService = new CryptoPrimitiveService(); var mobileStorageService = new MobileStorageService(preferencesStorage, liteDbStorage); + var deviceActionService = new DeviceActionService(mobileStorageService); var platformUtilsService = new MobilePlatformUtilsService(deviceActionService, messagingService, broadcasterService); diff --git a/src/Android/Properties/AndroidManifest.xml b/src/Android/Properties/AndroidManifest.xml index 51be9590d..bdf3d06f3 100644 --- a/src/Android/Properties/AndroidManifest.xml +++ b/src/Android/Properties/AndroidManifest.xml @@ -26,5 +26,14 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:networkSecurityConfig="@xml/network_security_config"> + + + diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index fd01b3e66..cd5aa51a3 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -1,6 +1,14 @@ -using System.Threading.Tasks; +using System; +using System.IO; +using System.Threading.Tasks; using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Support.V4.Content; +using Android.Webkit; using Bit.App.Abstractions; +using Bit.Core; +using Bit.Core.Abstractions; using Bit.Core.Enums; using Plugin.CurrentActivity; @@ -8,9 +16,16 @@ namespace Bit.Droid.Services { public class DeviceActionService : IDeviceActionService { + private readonly IStorageService _storageService; + private ProgressDialog _progressDialog; private Android.Widget.Toast _toast; + public DeviceActionService(IStorageService storageService) + { + _storageService = storageService; + } + public DeviceType DeviceType => DeviceType.Android; public void Toast(string text, bool longDuration = false) @@ -61,5 +76,100 @@ namespace Bit.Droid.Services } return Task.FromResult(0); } + + public bool OpenFile(byte[] fileData, string id, string fileName) + { + if(!CanOpenFile(fileName)) + { + return false; + } + var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower()); + if(extension == null) + { + return false; + } + var mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension); + if(mimeType == null) + { + return false; + } + + var activity = (MainActivity)CrossCurrentActivity.Current.Activity; + var cachePath = activity.CacheDir; + var filePath = Path.Combine(cachePath.Path, fileName); + File.WriteAllBytes(filePath, fileData); + var file = new Java.IO.File(cachePath, fileName); + if(!file.IsFile) + { + return false; + } + + try + { + var intent = new Intent(Intent.ActionView); + var uri = FileProvider.GetUriForFile(activity.ApplicationContext, + "com.x8bit.bitwarden.fileprovider", file); + intent.SetDataAndType(uri, mimeType); + intent.SetFlags(ActivityFlags.GrantReadUriPermission); + activity.StartActivity(intent); + return true; + } + catch { } + return false; + } + + public bool CanOpenFile(string fileName) + { + var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower()); + if(extension == null) + { + return false; + } + var mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension); + if(mimeType == null) + { + return false; + } + var activity = (MainActivity)CrossCurrentActivity.Current.Activity; + var intent = new Intent(Intent.ActionView); + intent.SetType(mimeType); + var activities = activity.PackageManager.QueryIntentActivities(intent, PackageInfoFlags.MatchDefaultOnly); + return (activities?.Count ?? 0) > 0; + } + + public async Task ClearCacheAsync() + { + try + { + DeleteDir(CrossCurrentActivity.Current.Activity.CacheDir); + await _storageService.SaveAsync(Constants.LastFileCacheClearKey, DateTime.UtcNow); + } + catch(Exception) { } + } + + private bool DeleteDir(Java.IO.File dir) + { + if(dir != null && dir.IsDirectory) + { + var children = dir.List(); + for(int i = 0; i < children.Length; i++) + { + var success = DeleteDir(new Java.IO.File(dir, children[i])); + if(!success) + { + return false; + } + } + return dir.Delete(); + } + else if(dir != null && dir.IsFile) + { + return dir.Delete(); + } + else + { + return false; + } + } } } \ No newline at end of file diff --git a/src/App/Abstractions/IDeviceActionService.cs b/src/App/Abstractions/IDeviceActionService.cs index 7b4554ad1..201225a4b 100644 --- a/src/App/Abstractions/IDeviceActionService.cs +++ b/src/App/Abstractions/IDeviceActionService.cs @@ -10,5 +10,8 @@ namespace Bit.App.Abstractions bool LaunchApp(string appName); Task ShowLoadingAsync(string text); Task HideLoadingAsync(); + bool OpenFile(byte[] fileData, string id, string fileName); + bool CanOpenFile(string fileName); + Task ClearCacheAsync(); } } \ No newline at end of file diff --git a/src/App/Pages/Vault/ViewPageViewModel.cs b/src/App/Pages/Vault/ViewPageViewModel.cs index 66c868988..3b502f05b 100644 --- a/src/App/Pages/Vault/ViewPageViewModel.cs +++ b/src/App/Pages/Vault/ViewPageViewModel.cs @@ -73,6 +73,7 @@ namespace Bit.App.Pages nameof(IsSecureNote), nameof(ShowUris), nameof(ShowFields), + nameof(ShowAttachments), nameof(ShowTotp), nameof(ColoredPassword), nameof(ShowIdentityAddress), @@ -253,10 +254,44 @@ namespace Bit.App.Pages if(Cipher.OrganizationId == null && !CanAccessPremium) { await _platformUtilsService.ShowDialogAsync(AppResources.PremiumRequired); + return; } + if(attachment.FileSize >= 10485760) // 10 MB + { + var confirmed = await _platformUtilsService.ShowDialogAsync( + string.Format(AppResources.AttachmentLargeWarning, attachment.SizeName), null, + AppResources.Yes, AppResources.No); + if(!confirmed) + { + return; + } + } + if(!_deviceActionService.CanOpenFile(attachment.FileName)) + { + await _platformUtilsService.ShowDialogAsync(AppResources.UnableToOpenFile); + return; + } + await _deviceActionService.ShowLoadingAsync(AppResources.Downloading); - await Task.Delay(2000); // TODO: download - await _deviceActionService.HideLoadingAsync(); + try + { + var data = await _cipherService.DownloadAndDecryptAttachmentAsync(attachment, Cipher.OrganizationId); + await _deviceActionService.HideLoadingAsync(); + if(data == null) + { + await _platformUtilsService.ShowDialogAsync(AppResources.UnableToDownloadFile); + return; + } + if(!_deviceActionService.OpenFile(data, attachment.Id, attachment.FileName)) + { + await _platformUtilsService.ShowDialogAsync(AppResources.UnableToOpenFile); + return; + } + } + catch + { + await _deviceActionService.HideLoadingAsync(); + } } private async void CopyAsync(string id, string text = null) diff --git a/src/Core/Abstractions/ICipherService.cs b/src/Core/Abstractions/ICipherService.cs index c41af500c..ff6f31c09 100644 --- a/src/Core/Abstractions/ICipherService.cs +++ b/src/Core/Abstractions/ICipherService.cs @@ -35,5 +35,6 @@ namespace Bit.Core.Abstractions Task UpdateLastUsedDateAsync(string id); Task UpsertAsync(CipherData cipher); Task UpsertAsync(List cipher); + Task DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId); } } \ No newline at end of file diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 42b0d6087..958ee0feb 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -9,5 +9,6 @@ public static string DefaultUriMatch = "defaultUriMatch"; public static string DisableAutoTotpCopyKey = "disableAutoTotpCopy"; public static string EnvironmentUrlsKey = "environmentUrls"; + public static string LastFileCacheClearKey = "lastFileCacheClear"; } } diff --git a/src/Core/Models/View/AttachmentView.cs b/src/Core/Models/View/AttachmentView.cs index b97dfdcfa..a0782d4fb 100644 --- a/src/Core/Models/View/AttachmentView.cs +++ b/src/Core/Models/View/AttachmentView.cs @@ -20,5 +20,17 @@ namespace Bit.Core.Models.View public string SizeName { get; set; } public string FileName { get; set; } public SymmetricCryptoKey Key { get; set; } + + public long FileSize + { + get + { + if(!string.IsNullOrWhiteSpace(Size) && long.TryParse(Size, out var s)) + { + return s; + } + return 0; + } + } } } diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index e8a98601b..fd1f54cbe 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -678,6 +678,27 @@ namespace Bit.Core.Services await DeleteAttachmentAsync(id, attachmentId); } + public async Task DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId) + { + try + { + var response = await _httpClient.GetAsync(new Uri(attachment.Url)); + if(!response.IsSuccessStatusCode) + { + return null; + } + var data = await response.Content.ReadAsByteArrayAsync(); + if(data == null) + { + return null; + } + var key = attachment.Key ?? await _cryptoService.GetOrgKeyAsync(organizationId); + return await _cryptoService.DecryptFromBytesAsync(data, key); + } + catch { } + return null; + } + // Helpers private async Task ShareAttachmentWithServerAsync(AttachmentView attachmentView, string cipherId, diff --git a/src/iOS/Services/DeviceActionService.cs b/src/iOS/Services/DeviceActionService.cs index a72c972b4..33bbf6367 100644 --- a/src/iOS/Services/DeviceActionService.cs +++ b/src/iOS/Services/DeviceActionService.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using Bit.App.Abstractions; +using Bit.Core; +using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.iOS.Core.Views; using CoreGraphics; @@ -14,9 +17,16 @@ namespace Bit.iOS.Services { public class DeviceActionService : IDeviceActionService { + private readonly IStorageService _storageService; + private Toast _toast; private UIAlertController _progressAlert; + public DeviceActionService(IStorageService storageService) + { + _storageService = storageService; + } + public DeviceType DeviceType => DeviceType.iOS; public bool LaunchApp(string appName) @@ -82,6 +92,57 @@ namespace Bit.iOS.Services return result.Task; } + public bool OpenFile(byte[] fileData, string id, string fileName) + { + var filePath = Path.Combine(GetTempPath(), fileName); + File.WriteAllBytes(filePath, fileData); + var url = NSUrl.FromFilename(filePath); + var viewer = UIDocumentInteractionController.FromUrl(url); + var controller = GetVisibleViewController(); + var rect = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad ? + new CGRect(100, 5, 320, 320) : controller.View.Frame; + return viewer.PresentOpenInMenu(rect, controller.View, true); + } + + public bool CanOpenFile(string fileName) + { + // Not sure of a way to check this ahead of time on iOS + return true; + } + + public async Task ClearCacheAsync() + { + var url = new NSUrl(GetTempPath()); + var tmpFiles = NSFileManager.DefaultManager.GetDirectoryContent(url, null, + NSDirectoryEnumerationOptions.SkipsHiddenFiles, out NSError error); + if(error == null && tmpFiles.Length > 0) + { + foreach(var item in tmpFiles) + { + NSFileManager.DefaultManager.Remove(item, out NSError itemError); + } + } + await _storageService.SaveAsync(Constants.LastFileCacheClearKey, DateTime.UtcNow); + } + + private UIViewController GetVisibleViewController(UIViewController controller = null) + { + controller = controller ?? UIApplication.SharedApplication.KeyWindow.RootViewController; + if(controller.PresentedViewController == null) + { + return controller; + } + if(controller.PresentedViewController is UINavigationController) + { + return ((UINavigationController)controller.PresentedViewController).VisibleViewController; + } + if(controller.PresentedViewController is UITabBarController) + { + return ((UITabBarController)controller.PresentedViewController).SelectedViewController; + } + return GetVisibleViewController(controller.PresentedViewController); + } + private UIViewController GetPresentedViewController() { var window = UIApplication.SharedApplication.KeyWindow; @@ -99,5 +160,12 @@ namespace Bit.iOS.Services return vc != null && (vc is UITabBarController || (vc.ChildViewControllers?.Any(c => c is UITabBarController) ?? false)); } + + // ref: //https://developer.xamarin.com/guides/ios/application_fundamentals/working_with_the_file_system/ + public string GetTempPath() + { + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return Path.Combine(documents, "..", "tmp"); + } } } \ No newline at end of file