In-app vault export support (#729)
* First pass at vault export UI * Password validation via cryptoService * Export service framework * support for constructing json export data * Support for constructing csv export data * Cleanup and simplification * Completion of vault export feature * Formatting and simplification * Use dialog instead of toast for invalid master password entry
This commit is contained in:
parent
7a6fe5ed5f
commit
33df456cfd
|
@ -201,7 +201,8 @@ namespace Bit.Droid
|
|||
|
||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
|
||||
{
|
||||
if(requestCode == Constants.SelectFileRequestCode && resultCode == Result.Ok)
|
||||
if(resultCode == Result.Ok &&
|
||||
(requestCode == Constants.SelectFileRequestCode || requestCode == Constants.SaveFileRequestCode))
|
||||
{
|
||||
Android.Net.Uri uri = null;
|
||||
string fileName = null;
|
||||
|
@ -222,6 +223,14 @@ namespace Bit.Droid
|
|||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(requestCode == Constants.SaveFileRequestCode)
|
||||
{
|
||||
_messagingService.Send("selectSaveFileResult",
|
||||
new Tuple<string, string>(uri.ToString(), fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using(var stream = ContentResolver.OpenInputStream(uri))
|
||||
|
|
|
@ -200,6 +200,61 @@ namespace Bit.Droid.Services
|
|||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
|
||||
|
||||
if(contentUri != null)
|
||||
{
|
||||
var uri = Android.Net.Uri.Parse(contentUri);
|
||||
var stream = activity.ContentResolver.OpenOutputStream(uri);
|
||||
// Using java bufferedOutputStream due to this issue:
|
||||
// https://github.com/xamarin/xamarin-android/issues/3498
|
||||
var javaStream = new Java.IO.BufferedOutputStream(stream);
|
||||
javaStream.Write(fileData);
|
||||
javaStream.Flush();
|
||||
javaStream.Close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prompt for location to save file
|
||||
var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower());
|
||||
if(extension == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension);
|
||||
if(mimeType == null)
|
||||
{
|
||||
if(extension == "json")
|
||||
{
|
||||
// Explicit support for json since older versions of Android don't recognize the extension
|
||||
mimeType = "text/json";
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var intent = new Intent(Intent.ActionCreateDocument);
|
||||
intent.SetType(mimeType);
|
||||
intent.AddCategory(Intent.CategoryOpenable);
|
||||
intent.PutExtra(Intent.ExtraTitle, fileName);
|
||||
|
||||
activity.StartActivityForResult(intent, Constants.SaveFileRequestCode);
|
||||
return true;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine(">>> {0}: {1}", ex.GetType(), ex.StackTrace);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task ClearCacheAsync()
|
||||
{
|
||||
|
|
|
@ -13,6 +13,7 @@ namespace Bit.App.Abstractions
|
|||
Task ShowLoadingAsync(string text);
|
||||
Task HideLoadingAsync();
|
||||
bool OpenFile(byte[] fileData, string id, string fileName);
|
||||
bool SaveFile(byte[] fileData, string id, string fileName, string contentUri);
|
||||
bool CanOpenFile(string fileName);
|
||||
Task ClearCacheAsync();
|
||||
Task SelectFileAsync();
|
||||
|
|
|
@ -66,6 +66,9 @@
|
|||
<Compile Update="Pages\Settings\FoldersPage.xaml.cs">
|
||||
<DependentUpon>FoldersPage.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Pages\Settings\ExportVaultPage.xaml.cs">
|
||||
<DependentUpon>ExportVaultPage.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Pages\Settings\OptionsPage.xaml.cs">
|
||||
<DependentUpon>OptionsPage.xaml</DependentUpon>
|
||||
</Compile>
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.ExportVaultPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
x:DataType="pages:ExportVaultPageViewModel"
|
||||
Title="{Binding PageTitle}">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:ExportVaultPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:UpperCaseConverter x:Key="toUpper" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ScrollView>
|
||||
<StackLayout Spacing="20">
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row, box-row-input, box-row-input-options-platform">
|
||||
<Label
|
||||
Text="{u:I18n FileFormat}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_fileFormatPicker"
|
||||
ItemsSource="{Binding FileFormatOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding FileFormatSelectedIndex}"
|
||||
StyleClass="box-value" />
|
||||
</StackLayout>
|
||||
<Grid StyleClass="box-row">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n MasterPassword}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<controls:MonoEntry
|
||||
x:Name="_masterPassword"
|
||||
Text="{Binding MasterPassword}"
|
||||
StyleClass="box-value"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
IsPassword="{Binding ShowPassword, Converter={StaticResource inverseBool}}"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
ReturnType="Go"
|
||||
ReturnCommand="{Binding ExportVaultCommand}" />
|
||||
<controls:FaButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowPasswordIcon}"
|
||||
Command="{Binding TogglePasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
AutomationProperties.Name="{u:I18n ToggleVisibility}" />
|
||||
</Grid>
|
||||
<Label
|
||||
Text="{u:I18n ExportVaultMasterPasswordDescription}"
|
||||
StyleClass="box-footer-label, box-footer-label-switch" />
|
||||
<Label
|
||||
StyleClass="box-footer-label, box-footer-label-switch"
|
||||
Margin="0, 20">
|
||||
<Label.FormattedText>
|
||||
<FormattedString>
|
||||
<Span
|
||||
Text="{Binding Converter={StaticResource toUpper}, ConverterParameter={u:I18n Warning}}"
|
||||
FontAttributes="Bold" />
|
||||
<Span Text=": " FontAttributes="Bold" />
|
||||
<Span Text="{u:I18n ExportVaultWarning}" />
|
||||
</FormattedString>
|
||||
</Label.FormattedText>
|
||||
</Label>
|
||||
<StackLayout Spacing="20">
|
||||
<Button Text="{u:I18n ExportVault}"
|
||||
Clicked="ExportVault_Clicked"
|
||||
HorizontalOptions="Fill"
|
||||
VerticalOptions="End" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</pages:BaseContentPage>
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Utilities;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class ExportVaultPage : BaseContentPage
|
||||
{
|
||||
private readonly ExportVaultPageViewModel _vm;
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
|
||||
public ExportVaultPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_vm = BindingContext as ExportVaultPageViewModel;
|
||||
_vm.Page = this;
|
||||
_fileFormatPicker.ItemDisplayBinding = new Binding("Value");
|
||||
MasterPasswordEntry = _masterPassword;
|
||||
}
|
||||
|
||||
protected async override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await _vm.InitAsync();
|
||||
_broadcasterService.Subscribe(nameof(AttachmentsPage), (message) =>
|
||||
{
|
||||
if(message.Command == "selectSaveFileResult")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
var data = message.Data as Tuple<string, string>;
|
||||
if(data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_vm.SaveFileSelected(data.Item1, data.Item2);
|
||||
});
|
||||
}
|
||||
});
|
||||
RequestFocus(_masterPassword);
|
||||
}
|
||||
|
||||
protected async override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
public Entry MasterPasswordEntry { get; set; }
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if(DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void ExportVault_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
if(DoOnce())
|
||||
{
|
||||
await _vm.ExportVaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Resources;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Utilities;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class ExportVaultPageViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly II18nService _i18nService;
|
||||
private readonly ICryptoService _cryptoService;
|
||||
private readonly IExportService _exportService;
|
||||
|
||||
private int _fileFormatSelectedIndex;
|
||||
private bool _showPassword;
|
||||
private string _masterPassword;
|
||||
private byte[] _exportResult;
|
||||
private string _defaultFilename;
|
||||
|
||||
public ExportVaultPageViewModel()
|
||||
{
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_i18nService = ServiceContainer.Resolve<II18nService>("i18nService");
|
||||
_cryptoService = ServiceContainer.Resolve<ICryptoService>("cryptoService");
|
||||
_exportService = ServiceContainer.Resolve<IExportService>("exportService");
|
||||
|
||||
PageTitle = AppResources.ExportVault;
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
ExportVaultCommand = new Command(async () => await ExportVaultAsync());
|
||||
|
||||
FileFormatOptions = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>("json", ".json"),
|
||||
new KeyValuePair<string, string>("csv", ".csv")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
FileFormatSelectedIndex = FileFormatOptions.FindIndex(k => k.Key == "json");
|
||||
}
|
||||
|
||||
public List<KeyValuePair<string, string>> FileFormatOptions { get; set; }
|
||||
|
||||
public int FileFormatSelectedIndex
|
||||
{
|
||||
get => _fileFormatSelectedIndex;
|
||||
set { SetProperty(ref _fileFormatSelectedIndex, value); }
|
||||
}
|
||||
|
||||
public bool ShowPassword
|
||||
{
|
||||
get => _showPassword;
|
||||
set => SetProperty(ref _showPassword, value,
|
||||
additionalPropertyNames: new string[] {nameof(ShowPasswordIcon)});
|
||||
}
|
||||
|
||||
public string MasterPassword
|
||||
{
|
||||
get => _masterPassword;
|
||||
set => SetProperty(ref _masterPassword, value);
|
||||
}
|
||||
|
||||
public Command TogglePasswordCommand { get; }
|
||||
|
||||
public string ShowPasswordIcon => ShowPassword ? "" : "";
|
||||
|
||||
public void TogglePassword()
|
||||
{
|
||||
ShowPassword = !ShowPassword;
|
||||
(Page as ExportVaultPage).MasterPasswordEntry.Focus();
|
||||
}
|
||||
|
||||
public Command ExportVaultCommand { get; }
|
||||
|
||||
public async Task ExportVaultAsync()
|
||||
{
|
||||
if(string.IsNullOrEmpty(_masterPassword))
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(_i18nService.T("InvalidMasterPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
var keyHash = await _cryptoService.HashPasswordAsync(_masterPassword, null);
|
||||
MasterPassword = string.Empty;
|
||||
|
||||
var storedKeyHash = await _cryptoService.GetKeyHashAsync();
|
||||
if(storedKeyHash != null && keyHash != null && storedKeyHash == keyHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = _exportService.GetExport(FileFormatOptions[FileFormatSelectedIndex].Key);
|
||||
var fileFormat = FileFormatOptions[FileFormatSelectedIndex].Key;
|
||||
_defaultFilename = _exportService.GetFileName(null, fileFormat);
|
||||
_exportResult = Encoding.ASCII.GetBytes(data.Result);
|
||||
|
||||
if(!_deviceActionService.SaveFile(_exportResult, null, _defaultFilename, null))
|
||||
{
|
||||
ClearResult();
|
||||
await _platformUtilsService.ShowDialogAsync(_i18nService.T("ExportVaultFailure"));
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
ClearResult();
|
||||
System.Diagnostics.Debug.WriteLine(">>> {0}: {1}", ex.GetType(), ex.StackTrace);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(_i18nService.T("InvalidMasterPassword"));
|
||||
}
|
||||
}
|
||||
|
||||
public async void SaveFileSelected(string contentUri, string filename)
|
||||
{
|
||||
if(_deviceActionService.SaveFile(_exportResult, null, filename ?? _defaultFilename, contentUri))
|
||||
{
|
||||
ClearResult();
|
||||
_platformUtilsService.ShowToast("success", null, _i18nService.T("ExportVaultSuccess"));
|
||||
return;
|
||||
}
|
||||
|
||||
ClearResult();
|
||||
await _platformUtilsService.ShowDialogAsync(_i18nService.T("ExportVaultFailure"));
|
||||
}
|
||||
|
||||
private void ClearResult()
|
||||
{
|
||||
_defaultFilename = null;
|
||||
_exportResult = null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -103,7 +103,7 @@ namespace Bit.App.Pages
|
|||
}
|
||||
else if(item.Name == AppResources.ExportVault)
|
||||
{
|
||||
_vm.Export();
|
||||
await Navigation.PushModalAsync(new NavigationPage(new ExportVaultPage()));
|
||||
}
|
||||
else if(item.Name == AppResources.ShareVault)
|
||||
{
|
||||
|
|
|
@ -127,11 +127,6 @@ namespace Bit.App.Pages
|
|||
_platformUtilsService.LaunchUri("https://help.bitwarden.com/article/import-data/");
|
||||
}
|
||||
|
||||
public void Export()
|
||||
{
|
||||
_platformUtilsService.LaunchUri("https://help.bitwarden.com/article/export-your-data/");
|
||||
}
|
||||
|
||||
public void WebVault()
|
||||
{
|
||||
var url = _environmentService.GetWebVaultUrl();
|
||||
|
|
|
@ -1491,6 +1491,24 @@ namespace Bit.App.Resources {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Enter your master password to export your vault data..
|
||||
/// </summary>
|
||||
public static string ExportVaultMasterPasswordDescription {
|
||||
get {
|
||||
return ResourceManager.GetString("ExportVaultMasterPasswordDescription", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This export contains your vault data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it..
|
||||
/// </summary>
|
||||
public static string ExportVaultWarning {
|
||||
get {
|
||||
return ResourceManager.GetString("ExportVaultWarning", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Extension Activated!.
|
||||
/// </summary>
|
||||
|
@ -1689,6 +1707,15 @@ namespace Bit.App.Resources {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to File Format.
|
||||
/// </summary>
|
||||
public static string FileFormat {
|
||||
get {
|
||||
return ResourceManager.GetString("FileFormat", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to File Source.
|
||||
/// </summary>
|
||||
|
@ -4065,6 +4092,15 @@ namespace Bit.App.Resources {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Warning.
|
||||
/// </summary>
|
||||
public static string Warning {
|
||||
get {
|
||||
return ResourceManager.GetString("Warning", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Website.
|
||||
/// </summary>
|
||||
|
|
|
@ -1590,4 +1590,22 @@
|
|||
<data name="Granted" xml:space="preserve">
|
||||
<value>Granted</value>
|
||||
</data>
|
||||
<data name="FileFormat" xml:space="preserve">
|
||||
<value>File Format</value>
|
||||
</data>
|
||||
<data name="ExportVaultMasterPasswordDescription" xml:space="preserve">
|
||||
<value>Enter your master password to export your vault data.</value>
|
||||
</data>
|
||||
<data name="ExportVaultWarning" xml:space="preserve">
|
||||
<value>This export contains your vault data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it.</value>
|
||||
</data>
|
||||
<data name="Warning" xml:space="preserve">
|
||||
<value>Warning</value>
|
||||
</data>
|
||||
<data name="ExportVaultFailure" xml:space="preserve">
|
||||
<value>There was a problem exporting your vault. If the problem persists, you'll need to export from the web vault.</value>
|
||||
</data>
|
||||
<data name="ExportVaultSuccess" xml:space="preserve">
|
||||
<value>Vault exported successfully</value>
|
||||
</data>
|
||||
</root>
|
|
@ -0,0 +1,29 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Utilities
|
||||
{
|
||||
public class UpperCaseConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if(targetType != typeof(string))
|
||||
{
|
||||
throw new InvalidOperationException("The target must be a string.");
|
||||
}
|
||||
|
||||
if(value == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return parameter.ToString().ToUpper();
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace Bit.Core.Abstractions
|
||||
{
|
||||
public interface IExportService
|
||||
{
|
||||
Task<string> GetExport(string format = "csv");
|
||||
Task<string> GetOrganizationExport(string organizationId, string format = "csv");
|
||||
string GetFileName(string prefix = null, string extension = "csv");
|
||||
}
|
||||
}
|
|
@ -33,5 +33,6 @@
|
|||
public static string PreviousPageKey = "previousPage";
|
||||
public const int SelectFileRequestCode = 42;
|
||||
public const int SelectFilePermissionRequestCode = 43;
|
||||
public const int SaveFileRequestCode = 44;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="15.0.0" />
|
||||
<PackageReference Include="LiteDB" Version="4.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="PCLCrypto" Version="2.0.147" />
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Card
|
||||
{
|
||||
public Card() { }
|
||||
|
||||
public Card(CardView obj)
|
||||
{
|
||||
CardholderName = obj.CardholderName;
|
||||
Brand = obj.Brand;
|
||||
Number = obj.Number;
|
||||
ExpMonth = obj.ExpMonth;
|
||||
ExpYear = obj.ExpYear;
|
||||
Code = obj.Code;
|
||||
}
|
||||
|
||||
public string CardholderName { get; set; }
|
||||
public string Brand { get; set; }
|
||||
public string Number { get; set; }
|
||||
public string ExpMonth { get; set; }
|
||||
public string ExpYear { get; set; }
|
||||
public string Code { get; set; }
|
||||
|
||||
public static CardView ToView(Card req, CardView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new CardView();
|
||||
}
|
||||
|
||||
view.CardholderName = req.CardholderName;
|
||||
view.Brand = req.Brand;
|
||||
view.Number = req.Number;
|
||||
view.ExpMonth = req.ExpMonth;
|
||||
view.ExpYear = req.ExpYear;
|
||||
view.Code = req.Code;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Cipher
|
||||
{
|
||||
public Cipher() { }
|
||||
|
||||
public Cipher(CipherView obj)
|
||||
{
|
||||
OrganizationId = obj.OrganizationId;
|
||||
FolderId = obj.FolderId;
|
||||
Type = obj.Type;
|
||||
Name = obj.Name;
|
||||
Notes = obj.Notes;
|
||||
Favorite = obj.Favorite;
|
||||
|
||||
Fields = obj.Fields?.Select(f => new Field(f)).ToList();
|
||||
|
||||
switch(obj.Type)
|
||||
{
|
||||
case CipherType.Login:
|
||||
Login = new Login(obj.Login);
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
SecureNote = new SecureNote(obj.SecureNote);
|
||||
break;
|
||||
case CipherType.Card:
|
||||
Card = new Card(obj.Card);
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
Identity = new Identity(obj.Identity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public string OrganizationId { get; set; }
|
||||
public string FolderId { get; set; }
|
||||
public CipherType Type { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Notes { get; set; }
|
||||
public bool Favorite { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public List<Field> Fields { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Login Login { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public SecureNote SecureNote { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Card Card { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Identity Identity { get; set; }
|
||||
|
||||
public CipherView ToView(Cipher req, CipherView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new CipherView();
|
||||
}
|
||||
|
||||
view.Type = req.Type;
|
||||
view.FolderId = req.FolderId;
|
||||
if(view.OrganizationId == null)
|
||||
{
|
||||
view.OrganizationId = req.OrganizationId;
|
||||
}
|
||||
|
||||
view.Name = req.Name;
|
||||
view.Notes = req.Notes;
|
||||
view.Favorite = req.Favorite;
|
||||
|
||||
view.Fields = req.Fields?.Select(f => Field.ToView(f)).ToList();
|
||||
|
||||
switch(req.Type)
|
||||
{
|
||||
case CipherType.Login:
|
||||
view.Login = Login.ToView(req.Login);
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
view.SecureNote = SecureNote.ToView(req.SecureNote);
|
||||
break;
|
||||
case CipherType.Card:
|
||||
view.Card = Card.ToView(req.Card);
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
view.Identity = Identity.ToView(req.Identity);
|
||||
break;
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
using System.Collections.Generic;
|
||||
using Bit.Core.Models.View;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class CipherWithId : Cipher
|
||||
{
|
||||
public CipherWithId(CipherView obj) : base(obj)
|
||||
{
|
||||
Id = obj.Id;
|
||||
CollectionIds = obj.CollectionIds;
|
||||
}
|
||||
|
||||
[JsonProperty(Order = int.MinValue)]
|
||||
public string Id { get; set; }
|
||||
[JsonProperty(Order = int.MaxValue)]
|
||||
public HashSet<string> CollectionIds { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Collection
|
||||
{
|
||||
public Collection() { }
|
||||
|
||||
public Collection(CollectionView obj)
|
||||
{
|
||||
OrganizationId = obj.OrganizationId;
|
||||
Name = obj.Name;
|
||||
ExternalId = obj.ExternalId;
|
||||
}
|
||||
|
||||
public string OrganizationId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
|
||||
public CollectionView ToView(Collection req, CollectionView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new CollectionView();
|
||||
}
|
||||
|
||||
view.Name = req.Name;
|
||||
view.ExternalId = req.ExternalId;
|
||||
if(view.OrganizationId == null)
|
||||
{
|
||||
view.OrganizationId = req.OrganizationId;
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
using Bit.Core.Models.View;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class CollectionWithId : Collection
|
||||
{
|
||||
public CollectionWithId(CollectionView obj) : base(obj)
|
||||
{
|
||||
Id = obj.Id;
|
||||
}
|
||||
|
||||
[JsonProperty(Order = int.MinValue)]
|
||||
public string Id { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Field
|
||||
{
|
||||
public Field() { }
|
||||
|
||||
public Field(FieldView obj)
|
||||
{
|
||||
Name = obj.Name;
|
||||
Value = obj.Value;
|
||||
Type = obj.Type;
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
public FieldType Type { get; set; }
|
||||
|
||||
public static FieldView ToView(Field req, FieldView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new FieldView();
|
||||
}
|
||||
|
||||
view.Type = req.Type;
|
||||
view.Value = req.Value;
|
||||
view.Name = req.Name;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Folder
|
||||
{
|
||||
public Folder() { }
|
||||
|
||||
public Folder(FolderView obj)
|
||||
{
|
||||
Name = obj.Name;
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public FolderView ToView(Folder req, FolderView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new FolderView();
|
||||
}
|
||||
|
||||
view.Name = req.Name;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
using Bit.Core.Models.View;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class FolderWithId : Folder
|
||||
{
|
||||
public FolderWithId(FolderView obj) : base(obj)
|
||||
{
|
||||
Id = obj.Id;
|
||||
}
|
||||
|
||||
[JsonProperty(Order = int.MinValue)]
|
||||
public string Id { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Identity
|
||||
{
|
||||
public Identity() { }
|
||||
|
||||
public Identity(IdentityView obj)
|
||||
{
|
||||
Title = obj.Title;
|
||||
FirstName = obj.FirstName;
|
||||
MiddleName = obj.MiddleName;
|
||||
LastName = obj.LastName;
|
||||
Address1 = obj.Address1;
|
||||
Address2 = obj.Address2;
|
||||
Address3 = obj.Address3;
|
||||
City = obj.City;
|
||||
State = obj.State;
|
||||
PostalCode = obj.PostalCode;
|
||||
Country = obj.Country;
|
||||
Company = obj.Company;
|
||||
Email = obj.Email;
|
||||
Phone = obj.Phone;
|
||||
SSN = obj.SSN;
|
||||
Username = obj.Username;
|
||||
PassportNumber = obj.PassportNumber;
|
||||
LicenseNumber = obj.LicenseNumber;
|
||||
}
|
||||
|
||||
public string Title { get; set; }
|
||||
public string FirstName { get; set; }
|
||||
public string MiddleName { get; set; }
|
||||
public string LastName { get; set; }
|
||||
public string Address1 { get; set; }
|
||||
public string Address2 { get; set; }
|
||||
public string Address3 { get; set; }
|
||||
public string City { get; set; }
|
||||
public string State { get; set; }
|
||||
public string PostalCode { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string Company { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Phone { get; set; }
|
||||
public string SSN { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string PassportNumber { get; set; }
|
||||
public string LicenseNumber { get; set; }
|
||||
|
||||
public static IdentityView ToView(Identity req, IdentityView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new IdentityView();
|
||||
}
|
||||
|
||||
view.Title = req.Title;
|
||||
view.FirstName = req.FirstName;
|
||||
view.MiddleName = req.MiddleName;
|
||||
view.LastName = req.LastName;
|
||||
view.Address1 = req.Address1;
|
||||
view.Address2 = req.Address2;
|
||||
view.Address3 = req.Address3;
|
||||
view.City = req.City;
|
||||
view.State = req.State;
|
||||
view.PostalCode = req.PostalCode;
|
||||
view.Country = req.Country;
|
||||
view.Company = req.Company;
|
||||
view.Email = req.Email;
|
||||
view.Phone = req.Phone;
|
||||
view.SSN = req.SSN;
|
||||
view.Username = req.Username;
|
||||
view.PassportNumber = req.PassportNumber;
|
||||
view.LicenseNumber = req.LicenseNumber;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class Login
|
||||
{
|
||||
public Login() { }
|
||||
|
||||
public Login(LoginView obj)
|
||||
{
|
||||
Uris = obj.Uris?.Select(u => new LoginUri(u)).ToList();
|
||||
|
||||
Username = obj.Username;
|
||||
Password = obj.Password;
|
||||
Totp = obj.Totp;
|
||||
}
|
||||
|
||||
public List<LoginUri> Uris { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Password { get; set; }
|
||||
public string Totp { get; set; }
|
||||
|
||||
public static LoginView ToView(Login req, LoginView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new LoginView();
|
||||
}
|
||||
|
||||
view.Uris = req.Uris?.Select(u => LoginUri.ToView(u)).ToList();
|
||||
|
||||
view.Username = req.Username;
|
||||
view.Password = req.Password;
|
||||
view.Totp = req.Totp;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class LoginUri
|
||||
{
|
||||
public LoginUri() { }
|
||||
|
||||
public LoginUri(LoginUriView obj)
|
||||
{
|
||||
Match = obj.Match;
|
||||
Uri = obj.Uri;
|
||||
}
|
||||
|
||||
public UriMatchType? Match { get; set; }
|
||||
public string Uri { get; set; }
|
||||
|
||||
public static LoginUriView ToView(LoginUri req, LoginUriView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new LoginUriView();
|
||||
}
|
||||
|
||||
view.Match = req.Match;
|
||||
view.Uri = req.Uri;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models.Export
|
||||
{
|
||||
public class SecureNote
|
||||
{
|
||||
public SecureNote() { }
|
||||
|
||||
public SecureNote(SecureNoteView obj)
|
||||
{
|
||||
Type = obj.Type;
|
||||
}
|
||||
|
||||
public SecureNoteType Type { get; set; }
|
||||
|
||||
public SecureNoteView ToView(SecureNote req, SecureNoteView view = null)
|
||||
{
|
||||
if(view == null)
|
||||
{
|
||||
view = new SecureNoteView();
|
||||
}
|
||||
|
||||
view.Type = req.Type;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Export;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
public class ExportService : IExportService
|
||||
{
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly ICipherService _cipherService;
|
||||
|
||||
private List<FolderView> _decryptedFolders;
|
||||
private List<CipherView> _decryptedCiphers;
|
||||
|
||||
public ExportService(
|
||||
IFolderService folderService,
|
||||
ICipherService cipherService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_cipherService = cipherService;
|
||||
}
|
||||
|
||||
public async Task<string> GetExport(string format = "csv")
|
||||
{
|
||||
_decryptedFolders = await _folderService.GetAllDecryptedAsync();
|
||||
_decryptedCiphers = await _cipherService.GetAllDecryptedAsync();
|
||||
|
||||
if(format == "csv")
|
||||
{
|
||||
var foldersMap = _decryptedFolders.Where(f => f.Id != null).ToDictionary(f => f.Id);
|
||||
|
||||
var exportCiphers = new List<ExportCipher>();
|
||||
foreach(var c in _decryptedCiphers)
|
||||
{
|
||||
// only export logins and secure notes
|
||||
if(c.Type != CipherType.Login && c.Type != CipherType.SecureNote)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(c.OrganizationId != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cipher = new ExportCipher();
|
||||
cipher.Folder = c.FolderId != null && foldersMap.ContainsKey(c.FolderId)
|
||||
? foldersMap[c.FolderId].Name : null;
|
||||
cipher.Favorite = c.Favorite ? "1" : null;
|
||||
BuildCommonCipher(cipher, c);
|
||||
exportCiphers.Add(cipher);
|
||||
}
|
||||
|
||||
using (var writer = new StringWriter())
|
||||
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
|
||||
{
|
||||
csv.WriteRecords(exportCiphers);
|
||||
csv.Flush();
|
||||
return writer.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var jsonDoc = new
|
||||
{
|
||||
Folders = _decryptedFolders.Where(f => f.Id != null).Select(f => new FolderWithId(f)),
|
||||
Items = _decryptedCiphers.Where(c => c.OrganizationId == null)
|
||||
.Select(c => new CipherWithId(c) {CollectionIds = null})
|
||||
};
|
||||
|
||||
return CoreHelpers.SerializeJson(jsonDoc,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> GetOrganizationExport(string organizationId, string format = "csv")
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public string GetFileName(string prefix = null, string extension = "csv")
|
||||
{
|
||||
var dateString = DateTime.Now.ToString("yyyyMMddHHmmss");
|
||||
|
||||
return string.Format("bitwarden{0}_export_{1}.{2}",
|
||||
!string.IsNullOrEmpty(prefix) ? ("_" + prefix) : string.Empty, dateString, extension);
|
||||
}
|
||||
|
||||
private void BuildCommonCipher(ExportCipher cipher, CipherView c)
|
||||
{
|
||||
cipher.Type = null;
|
||||
cipher.Name = c.Name;
|
||||
cipher.Notes = c.Notes;
|
||||
cipher.Fields = null;
|
||||
// Login props
|
||||
cipher.LoginUris = null;
|
||||
cipher.LoginUsername = null;
|
||||
cipher.LoginPassword = null;
|
||||
cipher.LoginTotp = null;
|
||||
|
||||
if(c.Fields != null)
|
||||
{
|
||||
foreach(var f in c.Fields)
|
||||
{
|
||||
if(cipher.Fields == null)
|
||||
{
|
||||
cipher.Fields = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
cipher.Fields += "\n";
|
||||
}
|
||||
|
||||
cipher.Fields += (f.Name ?? "") + ": " + f.Value;
|
||||
}
|
||||
}
|
||||
|
||||
switch(c.Type)
|
||||
{
|
||||
case CipherType.Login:
|
||||
cipher.Type = "login";
|
||||
cipher.LoginUsername = c.Login.Username;
|
||||
cipher.LoginPassword = c.Login.Password;
|
||||
cipher.LoginTotp = c.Login.Totp;
|
||||
|
||||
if(c.Login.Uris != null)
|
||||
{
|
||||
foreach(var u in c.Login.Uris)
|
||||
{
|
||||
if(cipher.LoginUris == null)
|
||||
{
|
||||
cipher.LoginUris = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
cipher.LoginUris += ",";
|
||||
}
|
||||
|
||||
cipher.LoginUris += u.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
cipher.Type = "note";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private class ExportCipher
|
||||
{
|
||||
[Name("folder")]
|
||||
public string Folder { get; set; }
|
||||
[Name("favorite")]
|
||||
public string Favorite { get; set; }
|
||||
[Name("type")]
|
||||
public string Type { get; set; }
|
||||
[Name("name")]
|
||||
public string Name { get; set; }
|
||||
[Name("notes")]
|
||||
public string Notes { get; set; }
|
||||
[Name("fields")]
|
||||
public string Fields { get; set; }
|
||||
[Name("login_uri")]
|
||||
public string LoginUris { get; set; }
|
||||
[Name("login_username")]
|
||||
public string LoginUsername { get; set; }
|
||||
[Name("login_password")]
|
||||
public string LoginPassword { get; set; }
|
||||
[Name("login_totp")]
|
||||
public string LoginTotp { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -190,6 +190,11 @@ namespace Bit.Core.Utilities
|
|||
}
|
||||
return JsonConvert.SerializeObject(obj, jsonSerializationSettings);
|
||||
}
|
||||
|
||||
public static string SerializeJson(object obj, JsonSerializerSettings jsonSerializationSettings)
|
||||
{
|
||||
return JsonConvert.SerializeObject(obj, jsonSerializationSettings);
|
||||
}
|
||||
|
||||
public static T DeserializeJson<T>(string json, bool ignoreNulls = false)
|
||||
{
|
||||
|
|
|
@ -58,7 +58,7 @@ namespace Bit.Core.Utilities
|
|||
var totpService = new TotpService(storageService, cryptoFunctionService);
|
||||
var authService = new AuthService(cryptoService, apiService, userService, tokenService, appIdService,
|
||||
i18nService, platformUtilsService, messagingService, lockService);
|
||||
// TODO: export service
|
||||
var exportService = new ExportService(folderService, cipherService);
|
||||
var auditService = new AuditService(cryptoFunctionService, apiService);
|
||||
var environmentService = new EnvironmentService(apiService, storageService);
|
||||
var eventService = new EventService(storageService, apiService, userService, cipherService);
|
||||
|
@ -80,6 +80,7 @@ namespace Bit.Core.Utilities
|
|||
Register<IPasswordGenerationService>("passwordGenerationService", passwordGenerationService);
|
||||
Register<ITotpService>("totpService", totpService);
|
||||
Register<IAuthService>("authService", authService);
|
||||
Register<IExportService>("exportService", exportService);
|
||||
Register<IAuditService>("auditService", auditService);
|
||||
Register<IEnvironmentService>("environmentService", environmentService);
|
||||
Register<IEventService>("eventService", eventService);
|
||||
|
|
|
@ -133,6 +133,12 @@ namespace Bit.iOS.Core.Services
|
|||
return true;
|
||||
}
|
||||
|
||||
public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri)
|
||||
{
|
||||
// OpenFile behavior is appropriate here as iOS prompts to save file
|
||||
return OpenFile(fileData, id, fileName);
|
||||
}
|
||||
|
||||
public async Task ClearCacheAsync()
|
||||
{
|
||||
var url = new NSUrl(GetTempPath());
|
||||
|
|
Loading…
Reference in New Issue