diff --git a/src/App/App.csproj b/src/App/App.csproj index a51e1f1d4..54e84fb16 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -43,6 +43,9 @@ PasswordHistoryPage.xaml + + AddEditPage.xaml + ViewPage.xaml diff --git a/src/App/Pages/Vault/AddEditPage.xaml b/src/App/Pages/Vault/AddEditPage.xaml new file mode 100644 index 000000000..87d4d6cd7 --- /dev/null +++ b/src/App/Pages/Vault/AddEditPage.xaml @@ -0,0 +1,603 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Pages/Vault/AddEditPage.xaml.cs b/src/App/Pages/Vault/AddEditPage.xaml.cs new file mode 100644 index 000000000..71525468e --- /dev/null +++ b/src/App/Pages/Vault/AddEditPage.xaml.cs @@ -0,0 +1,59 @@ +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using System.Collections.Generic; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class AddEditPage : BaseContentPage + { + private readonly IBroadcasterService _broadcasterService; + private AddEditPageViewModel _vm; + + public AddEditPage(string cipherId) + { + InitializeComponent(); + _broadcasterService = ServiceContainer.Resolve("broadcasterService"); + _vm = BindingContext as AddEditPageViewModel; + _vm.Page = this; + _vm.CipherId = cipherId; + SetActivityIndicator(); + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + _broadcasterService.Subscribe(nameof(ViewPage), async (message) => + { + if(message.Command == "syncCompleted") + { + var data = message.Data as Dictionary; + if(data.ContainsKey("successfully")) + { + var success = data["successfully"] as bool?; + if(success.HasValue && success.Value) + { + await _vm.LoadAsync(); + } + } + } + }); + await LoadOnAppearedAsync(_scrollView, true, () => _vm.LoadAsync()); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _broadcasterService.Unsubscribe(nameof(ViewPage)); + _vm.CleanUp(); + } + + private async void PasswordHistory_Tapped(object sender, System.EventArgs e) + { + if(DoOnce()) + { + await Navigation.PushModalAsync(new NavigationPage(new PasswordHistoryPage(_vm.CipherId))); + } + } + } +} diff --git a/src/App/Pages/Vault/AddEditPageViewModel.cs b/src/App/Pages/Vault/AddEditPageViewModel.cs new file mode 100644 index 000000000..9868096e2 --- /dev/null +++ b/src/App/Pages/Vault/AddEditPageViewModel.cs @@ -0,0 +1,478 @@ +using Bit.App.Abstractions; +using Bit.App.Resources; +using Bit.App.Utilities; +using Bit.Core.Abstractions; +using Bit.Core.Models.View; +using Bit.Core.Utilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class AddEditPageViewModel : BaseViewModel + { + private readonly IDeviceActionService _deviceActionService; + private readonly ICipherService _cipherService; + private readonly IUserService _userService; + private readonly ITotpService _totpService; + private readonly IPlatformUtilsService _platformUtilsService; + private readonly IAuditService _auditService; + private CipherView _cipher; + private List _fields; + private bool _canAccessPremium; + private bool _showPassword; + private bool _showCardCode; + private string _totpCode; + private string _totpCodeFormatted; + private string _totpSec; + private bool _totpLow; + private DateTime? _totpInterval = null; + + public AddEditPageViewModel() + { + _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _cipherService = ServiceContainer.Resolve("cipherService"); + _userService = ServiceContainer.Resolve("userService"); + _totpService = ServiceContainer.Resolve("totpService"); + _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + _auditService = ServiceContainer.Resolve("auditService"); + CopyCommand = new Command((id) => CopyAsync(id, null)); + CopyUriCommand = new Command(CopyUri); + CopyFieldCommand = new Command(CopyField); + LaunchUriCommand = new Command(LaunchUri); + TogglePasswordCommand = new Command(TogglePassword); + ToggleCardCodeCommand = new Command(ToggleCardCode); + CheckPasswordCommand = new Command(CheckPasswordAsync); + DownloadAttachmentCommand = new Command(DownloadAttachmentAsync); + + PageTitle = AppResources.ViewItem; + } + + public Command CopyCommand { get; set; } + public Command CopyUriCommand { get; set; } + public Command CopyFieldCommand { get; set; } + public Command LaunchUriCommand { get; set; } + public Command TogglePasswordCommand { get; set; } + public Command ToggleCardCodeCommand { get; set; } + public Command CheckPasswordCommand { get; set; } + public Command DownloadAttachmentCommand { get; set; } + public string CipherId { get; set; } + public CipherView Cipher + { + get => _cipher; + set => SetProperty(ref _cipher, value, + additionalPropertyNames: new string[] + { + nameof(IsLogin), + nameof(IsIdentity), + nameof(IsCard), + nameof(IsSecureNote), + nameof(ShowUris), + nameof(ShowAttachments), + nameof(ShowTotp), + nameof(ColoredPassword), + nameof(UpdatedText), + nameof(PasswordUpdatedText), + nameof(PasswordHistoryText), + nameof(ShowIdentityAddress), + }); + } + public List Fields + { + get => _fields; + set => SetProperty(ref _fields, value); + } + public bool CanAccessPremium + { + get => _canAccessPremium; + set => SetProperty(ref _canAccessPremium, value); + } + public bool ShowPassword + { + get => _showPassword; + set => SetProperty(ref _showPassword, value, + additionalPropertyNames: new string[] + { + nameof(ShowPasswordIcon) + }); + } + public bool ShowCardCode + { + get => _showCardCode; + set => SetProperty(ref _showCardCode, value, + additionalPropertyNames: new string[] + { + nameof(ShowCardCodeIcon) + }); + } + public bool IsLogin => Cipher?.Type == Core.Enums.CipherType.Login; + public bool IsIdentity => Cipher?.Type == Core.Enums.CipherType.Identity; + public bool IsCard => Cipher?.Type == Core.Enums.CipherType.Card; + public bool IsSecureNote => Cipher?.Type == Core.Enums.CipherType.SecureNote; + public FormattedString ColoredPassword => PasswordFormatter.FormatPassword(Cipher.Login.Password); + public FormattedString UpdatedText + { + get + { + var fs = new FormattedString(); + fs.Spans.Add(new Span + { + Text = string.Format("{0}:", AppResources.DateUpdated), + FontAttributes = FontAttributes.Bold + }); + fs.Spans.Add(new Span + { + Text = string.Format(" {0} {1}", + Cipher.RevisionDate.ToShortDateString(), + Cipher.RevisionDate.ToShortTimeString()) + }); + return fs; + } + } + public FormattedString PasswordUpdatedText + { + get + { + var fs = new FormattedString(); + fs.Spans.Add(new Span + { + Text = string.Format("{0}:", AppResources.DatePasswordUpdated), + FontAttributes = FontAttributes.Bold + }); + fs.Spans.Add(new Span + { + Text = string.Format(" {0} {1}", + Cipher.PasswordRevisionDisplayDate?.ToShortDateString(), + Cipher.PasswordRevisionDisplayDate?.ToShortTimeString()) + }); + return fs; + } + } + public FormattedString PasswordHistoryText + { + get + { + var fs = new FormattedString(); + fs.Spans.Add(new Span + { + Text = string.Format("{0}:", AppResources.PasswordHistory), + FontAttributes = FontAttributes.Bold + }); + fs.Spans.Add(new Span + { + Text = string.Format(" {0}", Cipher.PasswordHistory.Count.ToString()), + TextColor = (Color)Application.Current.Resources["PrimaryColor"] + }); + return fs; + } + } + public bool ShowUris => IsLogin && Cipher.Login.HasUris; + public bool ShowIdentityAddress => IsIdentity && ( + !string.IsNullOrWhiteSpace(Cipher.Identity.Address1) || + !string.IsNullOrWhiteSpace(Cipher.Identity.City) || + !string.IsNullOrWhiteSpace(Cipher.Identity.Country)); + public bool ShowAttachments => Cipher.HasAttachments && (CanAccessPremium || Cipher.OrganizationId != null); + public bool ShowTotp => IsLogin && !string.IsNullOrWhiteSpace(Cipher.Login.Totp) && + !string.IsNullOrWhiteSpace(TotpCodeFormatted); + public string ShowPasswordIcon => ShowPassword ? "" : ""; + public string ShowCardCodeIcon => ShowCardCode ? "" : ""; + public string TotpCodeFormatted + { + get => _totpCodeFormatted; + set => SetProperty(ref _totpCodeFormatted, value, + additionalPropertyNames: new string[] + { + nameof(ShowTotp) + }); + } + public string TotpSec + { + get => _totpSec; + set => SetProperty(ref _totpSec, value); + } + public bool TotpLow + { + get => _totpLow; + set + { + SetProperty(ref _totpLow, value); + Page.Resources["textTotp"] = Application.Current.Resources[value ? "text-danger" : "text-default"]; + } + } + + public async Task LoadAsync() + { + CleanUp(); + var cipher = await _cipherService.GetAsync(CipherId); + Cipher = await cipher.DecryptAsync(); + CanAccessPremium = await _userService.CanAccessPremiumAsync(); + Fields = Cipher.Fields?.Select(f => new AddEditPageFieldViewModel(f)).ToList(); + + if(Cipher.Type == Core.Enums.CipherType.Login && !string.IsNullOrWhiteSpace(Cipher.Login.Totp) && + (Cipher.OrganizationUseTotp || CanAccessPremium)) + { + await TotpUpdateCodeAsync(); + var interval = _totpService.GetTimeInterval(Cipher.Login.Totp); + await TotpTickAsync(interval); + _totpInterval = DateTime.UtcNow; + Device.StartTimer(new TimeSpan(0, 0, 1), () => + { + if(_totpInterval == null) + { + return false; + } + var task = TotpTickAsync(interval); + return true; + }); + } + } + + public void CleanUp() + { + _totpInterval = null; + } + + public void TogglePassword() + { + ShowPassword = !ShowPassword; + } + + public void ToggleCardCode() + { + ShowCardCode = !ShowCardCode; + } + + private async Task TotpUpdateCodeAsync() + { + if(Cipher == null || Cipher.Type != Core.Enums.CipherType.Login || Cipher.Login.Totp == null) + { + _totpInterval = null; + return; + } + _totpCode = await _totpService.GetCodeAsync(Cipher.Login.Totp); + if(_totpCode != null) + { + if(_totpCode.Length > 4) + { + var half = (int)Math.Floor(_totpCode.Length / 2M); + TotpCodeFormatted = string.Format("{0} {1}", _totpCode.Substring(0, half), + _totpCode.Substring(half)); + } + else + { + TotpCodeFormatted = _totpCode; + } + } + else + { + TotpCodeFormatted = null; + _totpInterval = null; + } + } + + private async Task TotpTickAsync(int intervalSeconds) + { + var epoc = CoreHelpers.EpocUtcNow() / 1000; + var mod = epoc % intervalSeconds; + var totpSec = intervalSeconds - mod; + TotpSec = totpSec.ToString(); + TotpLow = totpSec < 7; + if(mod == 0) + { + await TotpUpdateCodeAsync(); + } + } + + private async void CheckPasswordAsync() + { + if(!(Page as BaseContentPage).DoOnce()) + { + return; + } + if(string.IsNullOrWhiteSpace(Cipher.Login?.Password)) + { + return; + } + await _deviceActionService.ShowLoadingAsync(AppResources.CheckingPassword); + var matches = await _auditService.PasswordLeakedAsync(Cipher.Login.Password); + await _deviceActionService.HideLoadingAsync(); + if(matches > 0) + { + await _platformUtilsService.ShowDialogAsync(string.Format(AppResources.PasswordExposed, + matches.ToString("N0"))); + } + else + { + await _platformUtilsService.ShowDialogAsync(AppResources.PasswordSafe); + } + } + + private async void DownloadAttachmentAsync(AttachmentView attachment) + { + if(!(Page as BaseContentPage).DoOnce()) + { + return; + } + 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); + 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) + { + string name = null; + if(id == "LoginUsername") + { + text = Cipher.Login.Username; + name = AppResources.Username; + } + else if(id == "LoginPassword") + { + text = Cipher.Login.Password; + name = AppResources.Password; + } + else if(id == "LoginTotp") + { + text = _totpCode; + name = AppResources.VerificationCodeTotp; + } + else if(id == "LoginUri") + { + name = AppResources.URI; + } + else if(id == "FieldValue") + { + name = AppResources.Value; + } + else if(id == "CardNumber") + { + text = Cipher.Card.Number; + name = AppResources.Number; + } + else if(id == "CardCode") + { + text = Cipher.Card.Code; + name = AppResources.SecurityCode; + } + + if(text != null) + { + await _platformUtilsService.CopyToClipboardAsync(text); + if(!string.IsNullOrWhiteSpace(name)) + { + _platformUtilsService.ShowToast("info", null, string.Format(AppResources.ValueHasBeenCopied, name)); + } + } + } + + private void CopyUri(LoginUriView uri) + { + CopyAsync("LoginUri", uri.Uri); + } + + private void CopyField(FieldView field) + { + CopyAsync("FieldValue", field.Value); + } + + private void LaunchUri(LoginUriView uri) + { + if(uri.CanLaunch && (Page as BaseContentPage).DoOnce()) + { + _platformUtilsService.LaunchUri(uri.LaunchUri); + } + } + } + + public class AddEditPageFieldViewModel : BaseViewModel + { + private FieldView _field; + private bool _showHiddenValue; + + public AddEditPageFieldViewModel(FieldView field) + { + Field = field; + ToggleHiddenValueCommand = new Command(ToggleHiddenValue); + } + + public FieldView Field + { + get => _field; + set => SetProperty(ref _field, value, + additionalPropertyNames: new string[] + { + nameof(ValueText), + nameof(IsBooleanType), + nameof(IsHiddenType), + nameof(IsTextType), + nameof(ShowCopyButton), + }); + } + + public bool ShowHiddenValue + { + get => _showHiddenValue; + set => SetProperty(ref _showHiddenValue, value, + additionalPropertyNames: new string[] + { + nameof(ShowHiddenValueIcon) + }); + } + + public Command ToggleHiddenValueCommand { get; set; } + + public string ValueText => IsBooleanType ? (_field.Value == "true" ? "" : "") : _field.Value; + public string ShowHiddenValueIcon => _showHiddenValue ? "" : ""; + public bool IsTextType => _field.Type == Core.Enums.FieldType.Text; + public bool IsBooleanType => _field.Type == Core.Enums.FieldType.Boolean; + public bool IsHiddenType => _field.Type == Core.Enums.FieldType.Hidden; + public bool ShowCopyButton => _field.Type != Core.Enums.FieldType.Boolean && + !string.IsNullOrWhiteSpace(_field.Value); + + public void ToggleHiddenValue() + { + ShowHiddenValue = !ShowHiddenValue; + } + } +} diff --git a/src/App/Pages/Vault/ViewPage.xaml b/src/App/Pages/Vault/ViewPage.xaml index a13a91355..32e251c68 100644 --- a/src/App/Pages/Vault/ViewPage.xaml +++ b/src/App/Pages/Vault/ViewPage.xaml @@ -15,8 +15,7 @@ - + diff --git a/src/App/Pages/Vault/ViewPage.xaml.cs b/src/App/Pages/Vault/ViewPage.xaml.cs index 11b4c6f84..8599985c2 100644 --- a/src/App/Pages/Vault/ViewPage.xaml.cs +++ b/src/App/Pages/Vault/ViewPage.xaml.cs @@ -55,5 +55,13 @@ namespace Bit.App.Pages await Navigation.PushModalAsync(new NavigationPage(new PasswordHistoryPage(_vm.CipherId))); } } + + private async void EditToolbarItem_Clicked(object sender, System.EventArgs e) + { + if(DoOnce()) + { + await Navigation.PushModalAsync(new NavigationPage(new AddEditPage(_vm.CipherId))); + } + } } }