1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-26 13:13:28 +00:00

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:
Matt Portune
2020-02-14 16:10:58 -05:00
committed by GitHub
parent 7a6fe5ed5f
commit 33df456cfd
31 changed files with 1149 additions and 8 deletions

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)
{

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
}
}