mirror of
https://github.com/bitwarden/mobile
synced 2026-01-06 02:23:57 +00:00
PM-3349 PM-3350 MAUI Migration Initial
This commit is contained in:
105
src/Core/Pages/Vault/AttachmentsPage.xaml
Normal file
105
src/Core/Pages/Vault/AttachmentsPage.xaml
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
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"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:AttachmentsPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
<ContentPage.BindingContext>
|
||||
<pages:AttachmentsPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
|
||||
<ToolbarItem Text="{u:I18n Save}" Command="{Binding SubmitAsyncCommand}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:IsNotNullConverter x:Key="notNull" />
|
||||
<u:IsNullConverter x:Key="null" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ScrollView x:Name="_scrollView">
|
||||
<StackLayout Spacing="20">
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row" Padding="10, 20"
|
||||
IsVisible="{Binding HasAttachments, Converter={StaticResource inverseBool}}">
|
||||
<Label Text="{u:I18n NoAttachments}" HorizontalTextAlignment="Center" AutomationId="NoAttachmentsLabel" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Attachments}" IsVisible="{Binding HasAttachments}" AutomationId="AttachmentsList">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:AttachmentView">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<StackLayout Orientation="Horizontal" StyleClass="box-row" Spacing="10" AutomationId="AttachmentRow">
|
||||
<Label
|
||||
Text="{Binding FileName, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
VerticalTextAlignment="Center"
|
||||
HorizontalOptions="StartAndExpand"
|
||||
AutomationId="AttachmentFileNameLabel" />
|
||||
<Label
|
||||
Text="{Binding SizeName, Mode=OneWay}"
|
||||
StyleClass="box-sub-label"
|
||||
HorizontalTextAlignment="End"
|
||||
VerticalTextAlignment="Center"
|
||||
AutomationId="AttachmentFileSizeLabel" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Trash}}"
|
||||
Command="{Binding BindingContext.DeleteAttachmentCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
VerticalOptions="Center"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Delete}"
|
||||
AutomationId="AttachmentDeleteButton" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n AddNewAttachment, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row">
|
||||
<Label
|
||||
IsVisible="{Binding FileName, Converter={StaticResource null}}"
|
||||
Text="{u:I18n NoFileChosen}"
|
||||
LineBreakMode="CharacterWrap"
|
||||
StyleClass="text-sm, text-muted"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
HorizontalTextAlignment="Center"
|
||||
AutomationId="NoFileChosenLabel" />
|
||||
<Label
|
||||
IsVisible="{Binding FileName, Converter={StaticResource notNull}}"
|
||||
Text="{Binding FileName}"
|
||||
LineBreakMode="CharacterWrap"
|
||||
StyleClass="text-sm, text-muted"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
HorizontalTextAlignment="Center"
|
||||
AutomationId="NewAttachmentNameLabel" />
|
||||
</StackLayout>
|
||||
<Button Text="{u:I18n ChooseFile}" StyleClass="box-button-row"
|
||||
Clicked="ChooseFile_Clicked"
|
||||
AutomationId="ChooseFileButton"></Button>
|
||||
<Label
|
||||
Margin="0, 10, 0, 0"
|
||||
Text="{u:I18n MaxFileSize}"
|
||||
StyleClass="box-footer-label" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
73
src/Core/Pages/Vault/AttachmentsPage.xaml.cs
Normal file
73
src/Core/Pages/Vault/AttachmentsPage.xaml.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class AttachmentsPage : BaseContentPage
|
||||
{
|
||||
private AttachmentsPageViewModel _vm;
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
|
||||
public AttachmentsPage(string cipherId)
|
||||
{
|
||||
InitializeComponent();
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_vm = BindingContext as AttachmentsPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
SetActivityIndicator();
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
_broadcasterService.Subscribe(nameof(AttachmentsPage), (message) =>
|
||||
{
|
||||
if (message.Command == "selectFileResult")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
var data = message.Data as Tuple<byte[], string>;
|
||||
_vm.FileData = data.Item1;
|
||||
_vm.FileName = data.Item2;
|
||||
});
|
||||
}
|
||||
});
|
||||
await LoadOnAppearedAsync(_scrollView, true, () => _vm.InitAsync());
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform != Device.iOS)
|
||||
{
|
||||
_broadcasterService.Unsubscribe(nameof(AttachmentsPage));
|
||||
}
|
||||
}
|
||||
|
||||
private async void ChooseFile_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await _vm.ChooseFileAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
src/Core/Pages/Vault/AttachmentsPageViewModel.cs
Normal file
210
src/Core/Pages/Vault/AttachmentsPageViewModel.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
using Bit.App.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class AttachmentsPageViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly ICryptoService _cryptoService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly ILogger _logger;
|
||||
private CipherView _cipher;
|
||||
private Cipher _cipherDomain;
|
||||
private bool _hasAttachments;
|
||||
private bool _hasUpdatedKey;
|
||||
private bool _canAccessAttachments;
|
||||
private string _fileName;
|
||||
|
||||
public AttachmentsPageViewModel()
|
||||
{
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_fileService = ServiceContainer.Resolve<IFileService>();
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_cryptoService = ServiceContainer.Resolve<ICryptoService>("cryptoService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>();
|
||||
Attachments = new ExtendedObservableCollection<AttachmentView>();
|
||||
DeleteAttachmentCommand = new Command<AttachmentView>(DeleteAsync);
|
||||
SubmitAsyncCommand = new AsyncCommand(SubmitAsync, allowsMultipleExecutions: false);
|
||||
PageTitle = AppResources.Attachments;
|
||||
}
|
||||
|
||||
public string CipherId { get; set; }
|
||||
public CipherView Cipher
|
||||
{
|
||||
get => _cipher;
|
||||
set => SetProperty(ref _cipher, value);
|
||||
}
|
||||
public ExtendedObservableCollection<AttachmentView> 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 ICommand SubmitAsyncCommand { get; }
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
_cipherDomain = await _cipherService.GetAsync(CipherId);
|
||||
Cipher = await _cipherDomain.DecryptAsync();
|
||||
LoadAttachments();
|
||||
_hasUpdatedKey = await _cryptoService.HasUserKeyAsync();
|
||||
var canAccessPremium = await _stateService.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)
|
||||
{
|
||||
_platformUtilsService.LaunchUri("https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SubmitAsync()
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
if (!_hasUpdatedKey)
|
||||
{
|
||||
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;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
_cipherDomain = await _cipherService.SaveAttachmentRawWithServerAsync(
|
||||
_cipherDomain, Cipher, FileName, FileData);
|
||||
Cipher = await _cipherDomain.DecryptAsync();
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.AttachementAdded);
|
||||
LoadAttachments();
|
||||
FileData = null;
|
||||
FileName = null;
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
_logger.Exception(e);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Exception(e);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task ChooseFileAsync()
|
||||
{
|
||||
// Prevent Android from locking if vault timeout set to "immediate"
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
_vaultTimeoutService.DelayLockAndLogoutMs = 60000;
|
||||
}
|
||||
await _fileService.SelectFileAsync();
|
||||
}
|
||||
|
||||
private async void DeleteAsync(AttachmentView attachment)
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return;
|
||||
}
|
||||
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();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAttachments()
|
||||
{
|
||||
Attachments.ResetWithRange(Cipher.Attachments ?? new List<AttachmentView>());
|
||||
HasAttachments = Cipher.HasAttachments;
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs
Normal file
152
src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class AutofillCiphersPageViewModel : CipherSelectionPageViewModel
|
||||
{
|
||||
private CipherType? _fillType;
|
||||
|
||||
public string Uri { get; set; }
|
||||
|
||||
public override void Init(AppOptions appOptions)
|
||||
{
|
||||
Uri = appOptions?.Uri;
|
||||
_fillType = appOptions.FillType;
|
||||
|
||||
string name = null;
|
||||
if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false)
|
||||
{
|
||||
name = Uri.Substring(Constants.AndroidAppProtocol.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
name = CoreHelpers.GetDomain(Uri);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = "--";
|
||||
}
|
||||
Name = name;
|
||||
PageTitle = string.Format(AppResources.ItemsForUri, Name ?? "--");
|
||||
NoDataText = string.Format(AppResources.NoItemsForUri, Name ?? "--");
|
||||
}
|
||||
|
||||
protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync()
|
||||
{
|
||||
var groupedItems = new List<GroupingsPageListGroup>();
|
||||
var ciphers = await _cipherService.GetAllDecryptedByUrlAsync(Uri, null);
|
||||
|
||||
var matching = ciphers.Item1?.Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
|
||||
var hasMatching = matching?.Any() ?? false;
|
||||
if (matching?.Any() ?? false)
|
||||
{
|
||||
groupedItems.Add(
|
||||
new GroupingsPageListGroup(matching, AppResources.MatchingItems, matching.Count, false, true));
|
||||
}
|
||||
|
||||
var fuzzy = ciphers.Item2?.Select(c =>
|
||||
new GroupingsPageListItem { Cipher = c, FuzzyAutofill = true }).ToList();
|
||||
if (fuzzy?.Any() ?? false)
|
||||
{
|
||||
groupedItems.Add(
|
||||
new GroupingsPageListGroup(fuzzy, AppResources.PossibleMatchingItems, fuzzy.Count, false,
|
||||
!hasMatching));
|
||||
}
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
protected override async Task SelectCipherAsync(IGroupingsPageListItem item)
|
||||
{
|
||||
if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cipher = listItem.Cipher;
|
||||
|
||||
if (_deviceActionService.SystemMajorVersion() < 21)
|
||||
{
|
||||
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(cipher.Reprompt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var autofillResponse = AppResources.Yes;
|
||||
if (listItem.FuzzyAutofill)
|
||||
{
|
||||
var options = new List<string> { AppResources.Yes };
|
||||
if (cipher.Type == CipherType.Login &&
|
||||
Microsoft.Maui.Networking.Connectivity.NetworkAccess != Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
options.Add(AppResources.YesAndSave);
|
||||
}
|
||||
autofillResponse = await _deviceActionService.DisplayAlertAsync(null,
|
||||
string.Format(AppResources.BitwardenAutofillServiceMatchConfirm, Name), AppResources.No,
|
||||
options.ToArray());
|
||||
}
|
||||
if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login)
|
||||
{
|
||||
var uris = cipher.Login?.Uris?.ToList();
|
||||
if (uris == null)
|
||||
{
|
||||
uris = new List<LoginUriView>();
|
||||
}
|
||||
uris.Add(new LoginUriView
|
||||
{
|
||||
Uri = Uri,
|
||||
Match = null
|
||||
});
|
||||
cipher.Login.Uris = uris;
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher));
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave)
|
||||
{
|
||||
_autofillHandler.Autofill(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task AddCipherAsync()
|
||||
{
|
||||
if (_fillType.HasValue && _fillType != CipherType.Login)
|
||||
{
|
||||
var pageForOther = new CipherAddEditPage(type: _fillType, fromAutofill: true);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(pageForOther));
|
||||
return;
|
||||
}
|
||||
|
||||
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: Uri, name: Name,
|
||||
fromAutofill: true);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Core/Pages/Vault/BaseCipherViewModel.cs
Normal file
79
src/Core/Pages/Vault/BaseCipherViewModel.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.App.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public abstract class BaseCipherViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IAuditService _auditService;
|
||||
protected readonly IDeviceActionService _deviceActionService;
|
||||
protected readonly IFileService _fileService;
|
||||
protected readonly ILogger _logger;
|
||||
protected readonly IPlatformUtilsService _platformUtilsService;
|
||||
private CipherView _cipher;
|
||||
protected abstract string[] AdditionalPropertiesToRaiseOnCipherChanged { get; }
|
||||
|
||||
public BaseCipherViewModel()
|
||||
{
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_fileService = ServiceContainer.Resolve<IFileService>();
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_auditService = ServiceContainer.Resolve<IAuditService>("auditService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>("logger");
|
||||
|
||||
CheckPasswordCommand = new AsyncCommand(CheckPasswordAsync, allowsMultipleExecutions: false);
|
||||
}
|
||||
|
||||
public CipherView Cipher
|
||||
{
|
||||
get => _cipher;
|
||||
set => SetProperty(ref _cipher, value, additionalPropertyNames: AdditionalPropertiesToRaiseOnCipherChanged);
|
||||
}
|
||||
|
||||
public string CreationDate => string.Format(AppResources.CreatedXY, Cipher.CreationDate.ToShortDateString(), Cipher.CreationDate.ToShortTimeString());
|
||||
|
||||
public AsyncCommand CheckPasswordCommand { get; }
|
||||
|
||||
protected async Task CheckPasswordAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Cipher?.Login?.Password))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.CheckingPassword);
|
||||
var matches = await _auditService.PasswordLeakedAsync(Cipher.Login.Password);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
|
||||
await _platformUtilsService.ShowDialogAsync(matches > 0
|
||||
? string.Format(AppResources.PasswordExposed, matches.ToString("N0"))
|
||||
: AppResources.PasswordSafe);
|
||||
}
|
||||
catch (ApiException apiException)
|
||||
{
|
||||
_logger.Exception(apiException);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (apiException?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(apiException.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Exception(ex);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
847
src/Core/Pages/Vault/CipherAddEditPage.xaml
Normal file
847
src/Core/Pages/Vault/CipherAddEditPage.xaml
Normal file
@@ -0,0 +1,847 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.CipherAddEditPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:views="clr-namespace:Bit.Core.Models.View"
|
||||
xmlns:behaviors="clr-namespace:Bit.App.Behaviors"
|
||||
xmlns:effects="clr-namespace:Bit.App.Effects"
|
||||
xmlns:dts="clr-namespace:Bit.App.Lists.DataTemplateSelectors"
|
||||
xmlns:il="clr-namespace:Bit.App.Lists.ItemLayouts.CustomFields"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:CipherAddEditPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
<ContentPage.BindingContext>
|
||||
<pages:CipherAddEditPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Save}" Clicked="Save_Clicked" Order="Primary" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:StringHasValueConverter x:Key="stringHasValue" />
|
||||
<u:IsNotNullConverter x:Key="notNull" />
|
||||
<ToolbarItem Text="{u:I18n Cancel}" Clicked="Close_Clicked" Order="Primary" Priority="-1"
|
||||
x:Key="closeItem" x:Name="_closeItem" />
|
||||
<ToolbarItem Text="{u:I18n Collections}"
|
||||
x:Key="collectionsItem"
|
||||
x:Name="_collectionsItem"
|
||||
Clicked="Collections_Clicked"
|
||||
Order="Secondary" />
|
||||
<ToolbarItem Text="{u:I18n MoveToOrganization}"
|
||||
x:Key="shareItem"
|
||||
x:Name="_shareItem"
|
||||
Clicked="Share_Clicked"
|
||||
Order="Secondary" />
|
||||
<ToolbarItem IconImageSource="more_vert.png" Clicked="More_Clicked" Order="Primary" x:Name="_moreItem"
|
||||
x:Key="moreItem"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Options}" />
|
||||
<ToolbarItem Text="{u:I18n Attachments}"
|
||||
Clicked="Attachments_Clicked"
|
||||
Order="Secondary"
|
||||
x:Name="_attachmentsItem"
|
||||
x:Key="attachmentsItem" />
|
||||
<ToolbarItem Text="{u:I18n Delete}"
|
||||
Clicked="Delete_Clicked"
|
||||
Order="Secondary"
|
||||
IsDestructive="True"
|
||||
x:Name="_deleteItem"
|
||||
x:Key="deleteItem" />
|
||||
|
||||
<DataTemplate x:Key="TextCustomFieldDataTemplate">
|
||||
<il:TextCustomFieldItemLayout AutomationId="TextCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="BooleanCustomFieldDataTemplate">
|
||||
<il:BooleanCustomFieldItemLayout AutomationId="BooleanCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="HiddenCustomFieldDataTemplate">
|
||||
<il:HiddenCustomFieldItemLayout AutomationId="HiddenCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="LinkedCustomFieldDataTemplate">
|
||||
<il:LinkedCustomFieldItemLayout AutomationId="LinkedCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
|
||||
<dts:CustomFieldItemTemplateSelector x:Key="CustomFieldItemTemplateSelector"
|
||||
TextTemplate="{StaticResource TextCustomFieldDataTemplate}"
|
||||
BooleanTemplate="{StaticResource BooleanCustomFieldDataTemplate}"
|
||||
HiddenTemplate="{StaticResource HiddenCustomFieldDataTemplate}"
|
||||
LinkedTemplate="{StaticResource LinkedCustomFieldDataTemplate}"/>
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ScrollView x:Name="_scrollView" Padding="0, 0, 0, 20">
|
||||
<StackLayout Spacing="20">
|
||||
<StackLayout StyleClass="box">
|
||||
<Grid IsVisible="{Binding OwnershipPolicyInEffect}"
|
||||
Margin="0, 12, 0, 0"
|
||||
RowSpacing="0"
|
||||
ColumnSpacing="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Frame Padding="10"
|
||||
Margin="0"
|
||||
HasShadow="False"
|
||||
BackgroundColor="Transparent"
|
||||
BorderColor="{DynamicResource PrimaryColor}">
|
||||
<Label
|
||||
Text="{u:I18n PersonalOwnershipPolicyInEffect}"
|
||||
StyleClass="text-muted, text-sm, text-bold"
|
||||
HorizontalTextAlignment="Center"
|
||||
AutomationId="PersonalOwnershipPolicyLabel"/>
|
||||
</Frame>
|
||||
</Grid>
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n ItemInformation, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input"
|
||||
IsVisible="{Binding EditMode, Converter={StaticResource inverseBool}}">
|
||||
<Label
|
||||
Text="{u:I18n Type}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_typePicker"
|
||||
ItemsSource="{Binding TypeOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding TypeSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemTypePicker" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Name}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_nameEntry"
|
||||
Text="{Binding Cipher.Name}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Name}"
|
||||
AutomationId="ItemNameEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout IsVisible="{Binding IsLogin}" Spacing="0" Padding="0">
|
||||
<Grid StyleClass="box-row, box-row-input"
|
||||
RowDefinitions="Auto,*"
|
||||
ColumnDefinitions="*,Auto">
|
||||
<Label
|
||||
Text="{u:I18n Username}"
|
||||
StyleClass="box-label"/>
|
||||
<Entry
|
||||
x:Name="_loginUsernameEntry"
|
||||
Text="{Binding Cipher.Login.Username}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Username}"
|
||||
AutomationId="LoginUsernameEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Generate}}"
|
||||
Command="{Binding GenerateUsernameCommand}"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n GenerateUsername}"
|
||||
AutomationId="GenerateUsernameButton" />
|
||||
</Grid>
|
||||
<Grid StyleClass="box-row, box-row-input">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n Password}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<controls:MonoEntry
|
||||
x:Name="_loginPasswordEntry"
|
||||
Text="{Binding Cipher.Login.Password}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="{Binding PasswordFieldColSpan}"
|
||||
IsPassword="{Binding ShowPassword, Converter={StaticResource inverseBool}}"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
IsEnabled="{Binding Cipher.ViewPassword}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Password}"
|
||||
AutomationId="LoginPasswordEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.CheckCircle}}"
|
||||
Command="{Binding CheckPasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CheckPassword}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="CheckPasswordButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowPasswordIcon}"
|
||||
Command="{Binding TogglePasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationProperties.HelpText="{Binding PasswordVisibilityAccessibilityText}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="ViewPasswordButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Generate}}"
|
||||
Command="{Binding GeneratePasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="3"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n GeneratePassword}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="RegeneratePasswordButton" />
|
||||
</Grid>
|
||||
|
||||
<Label
|
||||
Text="{u:I18n Passkey}"
|
||||
StyleClass="box-label"
|
||||
Margin="0,10,0,0"
|
||||
IsVisible="{Binding ShowPasskeyInfo}"/>
|
||||
<Entry
|
||||
Text="{Binding CreationDate}"
|
||||
IsEnabled="False"
|
||||
StyleClass="box-value,text-muted"
|
||||
IsVisible="{Binding ShowPasskeyInfo}" />
|
||||
|
||||
<Grid StyleClass="box-row, box-row-input">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n AuthenticatorKey}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<Frame
|
||||
IsVisible="{Binding HasTotpValue, Converter={StaticResource inverseBool}}"
|
||||
Margin="0,5,0,0"
|
||||
StyleClass="btn-icon-row"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
VerticalOptions="FillAndExpand"
|
||||
Padding="0"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3">
|
||||
<Frame.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="ScanTotp_Clicked" />
|
||||
</Frame.GestureRecognizers>
|
||||
<controls:IconLabel
|
||||
Text="{Binding SetupTotpText}"
|
||||
Padding="0,15"
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="FillAndExpand"
|
||||
VerticalTextAlignment="Center"
|
||||
AutomationId="SetupTotpButton" />
|
||||
</Frame>
|
||||
<controls:MonoEntry
|
||||
x:Name="_loginTotpEntry"
|
||||
Text="{Binding Cipher.Login.Totp}"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
IsVisible="{Binding HasTotpValue}"
|
||||
IsPassword="{Binding Cipher.ViewPassword, Converter={StaticResource inverseBool}}"
|
||||
IsEnabled="{Binding Cipher.ViewPassword}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="{Binding TotpColumnSpan}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n AuthenticatorKey}"
|
||||
AutomationId="LoginTotpEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
IsVisible="{Binding HasTotpValue}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyTotp}"
|
||||
AutomationId="CopyTotpValueButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Camera}}"
|
||||
Clicked="ScanTotp_Clicked"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
IsVisible="{Binding HasTotpValue}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ScanQrTitle}"
|
||||
/>
|
||||
</Grid>
|
||||
</StackLayout>
|
||||
<StackLayout IsVisible="{Binding IsCard}" Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n CardholderName}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_cardholderNameEntry"
|
||||
Text="{Binding Cipher.Card.CardholderName}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="CardholderNameEntry" />
|
||||
</StackLayout>
|
||||
<Grid StyleClass="box-row, box-row-input">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n Number}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<controls:MonoEntry
|
||||
x:Name="_cardNumberEntry"
|
||||
Text="{Binding Cipher.Card.Number}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsPassword="{Binding ShowCardNumber, Converter={StaticResource inverseBool}}"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Number}"
|
||||
AutomationId="CardNumberEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowCardNumberIcon}"
|
||||
Command="{Binding ToggleCardNumberCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationId="ShowCardNumberButton" />
|
||||
</Grid>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Brand}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_cardBrandPicker"
|
||||
ItemsSource="{Binding CardBrandOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding CardBrandSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="CardBrandPicker" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n ExpirationMonth}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_cardExpMonthPicker"
|
||||
ItemsSource="{Binding CardExpMonthOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding CardExpMonthSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="CardExpirationMonthPicker" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n ExpirationYear}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_cardExpYearEntry"
|
||||
Text="{Binding Cipher.Card.ExpYear}"
|
||||
StyleClass="box-value"
|
||||
Keyboard="Numeric"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ExpirationYear}"
|
||||
AutomationId="CardExpirationYearEntry" />
|
||||
</StackLayout>
|
||||
<Grid StyleClass="box-row, box-row-input">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n SecurityCode}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<controls:MonoEntry
|
||||
x:Name="_cardCodeEntry"
|
||||
Text="{Binding Cipher.Card.Code}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Keyboard="Numeric"
|
||||
IsPassword="{Binding ShowCardCode, Converter={StaticResource inverseBool}}"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n SecurityCode}"
|
||||
AutomationId="CardSecurityCodeEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowCardCodeIcon}"
|
||||
Command="{Binding ToggleCardCodeCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationId="CardShowSecurityCodeButton" />
|
||||
</Grid>
|
||||
</StackLayout>
|
||||
<StackLayout IsVisible="{Binding IsIdentity}" Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Title}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_identityTitlePicker"
|
||||
ItemsSource="{Binding IdentityTitleOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding IdentityTitleSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityTitlePicker" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n FirstName}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityFirstNameEntry"
|
||||
Text="{Binding Cipher.Identity.FirstName}"
|
||||
StyleClass="box-value,capitalize-word-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n FirstName}"
|
||||
AutomationId="IdentityFirstNameEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n MiddleName}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityMiddleNameEntry"
|
||||
Text="{Binding Cipher.Identity.MiddleName}"
|
||||
StyleClass="box-value,capitalize-word-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n MiddleName}"
|
||||
AutomationId="IdentityMiddleNameEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n LastName}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityLastNameEntry"
|
||||
Text="{Binding Cipher.Identity.LastName}"
|
||||
StyleClass="box-value,capitalize-word-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n LastName}"
|
||||
AutomationId="IdentityLastNameEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Username}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityUsernameEntry"
|
||||
Text="{Binding Cipher.Identity.Username}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Username}"
|
||||
AutomationId="IdentityUsernameEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Company}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityCompanyEntry"
|
||||
Text="{Binding Cipher.Identity.Company}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Company}"
|
||||
AutomationId="IdentityCompanyEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n SSN}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identitySsnEntry"
|
||||
Text="{Binding Cipher.Identity.SSN}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n SSN}"
|
||||
AutomationId="IdentitySsnEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n PassportNumber}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityPassportNumberEntry"
|
||||
Text="{Binding Cipher.Identity.PassportNumber}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n PassportNumber}"
|
||||
AutomationId="IdentityPassportNumberEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n LicenseNumber}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityLicenseNumberEntry"
|
||||
Text="{Binding Cipher.Identity.LicenseNumber}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n LicenseNumber}"
|
||||
AutomationId="IdentityLicenseNumberEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Email}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityEmailEntry"
|
||||
Keyboard="Email"
|
||||
Text="{Binding Cipher.Identity.Email}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Email}"
|
||||
AutomationId="IdentityEmailEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Phone}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityPhoneEntry"
|
||||
Text="{Binding Cipher.Identity.Phone}"
|
||||
Keyboard="Telephone"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Phone}"
|
||||
AutomationId="IdentityPhoneEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Address1}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityAddress1Entry"
|
||||
Text="{Binding Cipher.Identity.Address1}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Address1}"
|
||||
AutomationId="IdentityAddressOneEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Address2}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityAddress2Entry"
|
||||
Text="{Binding Cipher.Identity.Address2}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Address2}"
|
||||
AutomationId="IdentityAddressTwoEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Address3}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityAddress3Entry"
|
||||
Text="{Binding Cipher.Identity.Address3}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Address3}"
|
||||
AutomationId="IdentityAddressThreeEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n CityTown}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityCityEntry"
|
||||
Text="{Binding Cipher.Identity.City}"
|
||||
StyleClass="box-value,capitalize-sentence-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CityTown}"
|
||||
AutomationId="IdentityCityEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n StateProvince}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityStateEntry"
|
||||
Text="{Binding Cipher.Identity.State}"
|
||||
StyleClass="box-value,capitalize-sentence-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n StateProvince}"
|
||||
AutomationId="IdentityStateEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n ZipPostalCode}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityPostalCodeEntry"
|
||||
Text="{Binding Cipher.Identity.PostalCode}"
|
||||
StyleClass="box-value"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ZipPostalCode}"
|
||||
AutomationId="IdentityPostalCodeEntry" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Country}"
|
||||
StyleClass="box-label" />
|
||||
<Entry
|
||||
x:Name="_identityCountryEntry"
|
||||
Text="{Binding Cipher.Identity.Country}"
|
||||
StyleClass="box-value,capitalize-sentence-input"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Country}"
|
||||
AutomationId="IdentityCountryEntry" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding IsLogin}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n URIs, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Uris}">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:LoginUriView">
|
||||
<Grid StyleClass="box-row, box-row-input" AutomationId="UriListGrid" >
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n URI}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0" />
|
||||
<Entry
|
||||
Text="{Binding Uri}"
|
||||
Keyboard="Url"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n URI}"
|
||||
AutomationId="LoginUriEntry" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Cog}}"
|
||||
Command="{Binding BindingContext.UriOptionsCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Options}"
|
||||
AutomationId="LoginUriOptionsButton" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
<Button Text="{u:I18n NewUri}" StyleClass="box-button-row"
|
||||
Clicked="NewUri_Clicked"
|
||||
AutomationId="LoginAddNewUriButton"></Button>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Miscellaneous, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n Folder}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_folderPicker"
|
||||
ItemsSource="{Binding FolderOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding FolderSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="FolderPicker" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-switch">
|
||||
<Label
|
||||
Text="{u:I18n Favorite}"
|
||||
StyleClass="box-label-regular"
|
||||
HorizontalOptions="StartAndExpand" />
|
||||
<Switch
|
||||
IsToggled="{Binding Cipher.Favorite}"
|
||||
StyleClass="box-value"
|
||||
HorizontalOptions="End"
|
||||
AutomationId="ItemFavoriteToggle" />
|
||||
</StackLayout>
|
||||
<StackLayout x:Name="_passwordPrompt" StyleClass="box-row, box-row-switch">
|
||||
<Label
|
||||
Text="{u:I18n PasswordPrompt}"
|
||||
StyleClass="box-label-regular" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.QuestionCircle}}"
|
||||
Command="{Binding PasswordPromptHelpCommand}"
|
||||
TextColor="{DynamicResource MutedColor}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n MasterPasswordRePromptHelp}"
|
||||
HorizontalOptions="StartAndExpand" />
|
||||
<Switch
|
||||
IsToggled="{Binding PasswordPrompt}"
|
||||
Toggled="PasswordPrompt_Toggled"
|
||||
StyleClass="box-value"
|
||||
HorizontalOptions="End"
|
||||
AutomationId="MasterPasswordRepromptToggle" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Notes, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Editor
|
||||
x:Name="_notesEditor"
|
||||
AutoSize="TextChanges"
|
||||
StyleClass="box-value"
|
||||
effects:ScrollEnabledEffect.IsScrollEnabled="false"
|
||||
Text="{Binding Cipher.Notes}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Notes}"
|
||||
AutomationId="ItemNotesEntry">
|
||||
<Editor.Behaviors>
|
||||
<behaviors:EditorPreventAutoBottomScrollingOnFocusedBehavior ParentScrollView="{x:Reference _scrollView}" />
|
||||
</Editor.Behaviors>
|
||||
<Editor.Effects>
|
||||
<effects:ScrollEnabledEffect />
|
||||
</Editor.Effects>
|
||||
</Editor>
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" IsVisible="{Binding ShowNotesSeparator}" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n CustomFields, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout
|
||||
Spacing="0"
|
||||
BindableLayout.ItemsSource="{Binding Fields}"
|
||||
BindableLayout.ItemTemplateSelector="{StaticResource CustomFieldItemTemplateSelector}"
|
||||
AutomationId="CustomFieldsList" />
|
||||
<Button Text="{u:I18n NewCustomField}" StyleClass="box-button-row"
|
||||
Clicked="NewField_Clicked"
|
||||
AutomationId="NewCustomFieldButton"></Button>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding ShowOwnershipOptions}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Ownership, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row, box-row-input">
|
||||
<Label
|
||||
Text="{u:I18n WhoOwnsThisItem}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_ownershipPicker"
|
||||
ItemsSource="{Binding OwnershipOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding OwnershipSelectedIndex}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemOwnershipPicker" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding ShowCollections}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Collections, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout Spacing="0" Padding="0"
|
||||
IsVisible="{Binding HasCollections, Converter={StaticResource inverseBool}}">
|
||||
<StackLayout StyleClass="box-row, box-row-switch">
|
||||
<Label Text="{u:I18n NoCollectionsToList}"
|
||||
AutomationId="NoCollectionsToListLabel" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView
|
||||
ItemsSource="{Binding Collections}"
|
||||
IsVisible="{Binding HasCollections}">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="pages:CollectionViewModel">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row, box-row-switch" AutomationId="CollectionItemCell">
|
||||
<Label
|
||||
Text="{Binding Collection.Name}"
|
||||
StyleClass="box-label-regular"
|
||||
HorizontalOptions="StartAndExpand"
|
||||
AutomationId="CollectionItemNameLabel" />
|
||||
<Switch
|
||||
IsToggled="{Binding Checked}"
|
||||
StyleClass="box-value"
|
||||
HorizontalOptions="End"
|
||||
AutomationId="CollectionItemSwitch" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
</pages:BaseContentPage>
|
||||
402
src/Core/Pages/Vault/CipherAddEditPage.xaml.cs
Normal file
402
src/Core/Pages/Vault/CipherAddEditPage.xaml.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls.PlatformConfiguration;
|
||||
using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class CipherAddEditPage : BaseContentPage
|
||||
{
|
||||
private readonly AppOptions _appOptions;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
private readonly IUserVerificationService _userVerificationService;
|
||||
|
||||
private CipherAddEditPageViewModel _vm;
|
||||
private bool _fromAutofill;
|
||||
|
||||
public CipherAddEditPage(
|
||||
string cipherId = null,
|
||||
CipherType? type = null,
|
||||
string folderId = null,
|
||||
string collectionId = null,
|
||||
string organizationId = null,
|
||||
string name = null,
|
||||
string uri = null,
|
||||
bool fromAutofill = false,
|
||||
AppOptions appOptions = null,
|
||||
bool cloneMode = false,
|
||||
CipherDetailsPage cipherDetailsPage = null)
|
||||
{
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_userVerificationService = ServiceContainer.Resolve<IUserVerificationService>();
|
||||
|
||||
_appOptions = appOptions;
|
||||
_fromAutofill = fromAutofill;
|
||||
FromAutofillFramework = _appOptions?.FromAutofillFramework ?? false;
|
||||
InitializeComponent();
|
||||
_vm = BindingContext as CipherAddEditPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
_vm.FolderId = folderId == "none" ? null : folderId;
|
||||
_vm.CollectionIds = collectionId != null ? new HashSet<string>(new List<string> { collectionId }) : null;
|
||||
_vm.OrganizationId = organizationId;
|
||||
_vm.Type = type;
|
||||
_vm.DefaultName = name ?? appOptions?.SaveName;
|
||||
_vm.DefaultUri = uri ?? appOptions?.Uri;
|
||||
_vm.CloneMode = cloneMode;
|
||||
_vm.CipherDetailsPage = cipherDetailsPage;
|
||||
_vm.Init();
|
||||
SetActivityIndicator();
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (_vm.EditMode && !_vm.CloneMode && Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.Add(_attachmentsItem);
|
||||
ToolbarItems.Add(_deleteItem);
|
||||
}
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
ToolbarItems.Add(_closeItem);
|
||||
if (_vm.EditMode && !_vm.CloneMode)
|
||||
{
|
||||
ToolbarItems.Add(_moreItem);
|
||||
}
|
||||
_vm.ShowNotesSeparator = true;
|
||||
|
||||
_typePicker.On<iOS>().SetUpdateMode(UpdateMode.WhenFinished);
|
||||
_ownershipPicker.On<iOS>().SetUpdateMode(UpdateMode.WhenFinished);
|
||||
}
|
||||
|
||||
_typePicker.ItemDisplayBinding = new Binding("Key");
|
||||
_cardBrandPicker.ItemDisplayBinding = new Binding("Key");
|
||||
_cardExpMonthPicker.ItemDisplayBinding = new Binding("Key");
|
||||
_identityTitlePicker.ItemDisplayBinding = new Binding("Key");
|
||||
_folderPicker.ItemDisplayBinding = new Binding("Key");
|
||||
_ownershipPicker.ItemDisplayBinding = new Binding("Key");
|
||||
|
||||
_loginPasswordEntry.Keyboard = Keyboard.Create(KeyboardFlags.None);
|
||||
|
||||
_nameEntry.ReturnType = ReturnType.Next;
|
||||
_nameEntry.ReturnCommand = new Command(() =>
|
||||
{
|
||||
if (_vm.Cipher.Type == CipherType.Login)
|
||||
{
|
||||
_loginUsernameEntry.Focus();
|
||||
}
|
||||
else if (_vm.Cipher.Type == CipherType.Card)
|
||||
{
|
||||
_cardholderNameEntry.Focus();
|
||||
}
|
||||
});
|
||||
|
||||
_loginUsernameEntry.ReturnType = ReturnType.Next;
|
||||
_loginUsernameEntry.ReturnCommand = new Command(() => _loginPasswordEntry.Focus());
|
||||
_loginPasswordEntry.ReturnType = ReturnType.Next;
|
||||
_loginPasswordEntry.ReturnCommand = new Command(() => _loginTotpEntry.Focus());
|
||||
|
||||
_cardholderNameEntry.ReturnType = ReturnType.Next;
|
||||
_cardholderNameEntry.ReturnCommand = new Command(() => _cardNumberEntry.Focus());
|
||||
_cardExpYearEntry.ReturnType = ReturnType.Next;
|
||||
_cardExpYearEntry.ReturnCommand = new Command(() => _cardCodeEntry.Focus());
|
||||
|
||||
_identityFirstNameEntry.ReturnType = ReturnType.Next;
|
||||
_identityFirstNameEntry.ReturnCommand = new Command(() => _identityMiddleNameEntry.Focus());
|
||||
_identityMiddleNameEntry.ReturnType = ReturnType.Next;
|
||||
_identityMiddleNameEntry.ReturnCommand = new Command(() => _identityLastNameEntry.Focus());
|
||||
_identityLastNameEntry.ReturnType = ReturnType.Next;
|
||||
_identityLastNameEntry.ReturnCommand = new Command(() => _identityUsernameEntry.Focus());
|
||||
_identityUsernameEntry.ReturnType = ReturnType.Next;
|
||||
_identityUsernameEntry.ReturnCommand = new Command(() => _identityCompanyEntry.Focus());
|
||||
_identityCompanyEntry.ReturnType = ReturnType.Next;
|
||||
_identityCompanyEntry.ReturnCommand = new Command(() => _identitySsnEntry.Focus());
|
||||
_identitySsnEntry.ReturnType = ReturnType.Next;
|
||||
_identitySsnEntry.ReturnCommand = new Command(() => _identityPassportNumberEntry.Focus());
|
||||
_identityPassportNumberEntry.ReturnType = ReturnType.Next;
|
||||
_identityPassportNumberEntry.ReturnCommand = new Command(() => _identityLicenseNumberEntry.Focus());
|
||||
_identityLicenseNumberEntry.ReturnType = ReturnType.Next;
|
||||
_identityLicenseNumberEntry.ReturnCommand = new Command(() => _identityEmailEntry.Focus());
|
||||
_identityEmailEntry.ReturnType = ReturnType.Next;
|
||||
_identityEmailEntry.ReturnCommand = new Command(() => _identityPhoneEntry.Focus());
|
||||
_identityPhoneEntry.ReturnType = ReturnType.Next;
|
||||
_identityPhoneEntry.ReturnCommand = new Command(() => _identityAddress1Entry.Focus());
|
||||
_identityAddress1Entry.ReturnType = ReturnType.Next;
|
||||
_identityAddress1Entry.ReturnCommand = new Command(() => _identityAddress2Entry.Focus());
|
||||
_identityAddress2Entry.ReturnType = ReturnType.Next;
|
||||
_identityAddress2Entry.ReturnCommand = new Command(() => _identityAddress3Entry.Focus());
|
||||
_identityAddress3Entry.ReturnType = ReturnType.Next;
|
||||
_identityAddress3Entry.ReturnCommand = new Command(() => _identityCityEntry.Focus());
|
||||
_identityCityEntry.ReturnType = ReturnType.Next;
|
||||
_identityCityEntry.ReturnCommand = new Command(() => _identityStateEntry.Focus());
|
||||
_identityStateEntry.ReturnType = ReturnType.Next;
|
||||
_identityStateEntry.ReturnCommand = new Command(() => _identityPostalCodeEntry.Focus());
|
||||
_identityPostalCodeEntry.ReturnType = ReturnType.Next;
|
||||
_identityPostalCodeEntry.ReturnCommand = new Command(() => _identityCountryEntry.Focus());
|
||||
}
|
||||
|
||||
public bool FromAutofillFramework { get; set; }
|
||||
public CipherAddEditPageViewModel ViewModel => _vm;
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (!await AppHelpers.IsVaultTimeoutImmediateAsync())
|
||||
{
|
||||
await _vaultTimeoutService.CheckVaultTimeoutAsync();
|
||||
}
|
||||
if (await _vaultTimeoutService.IsLockedAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
await LoadOnAppearedAsync(_scrollView, true, async () =>
|
||||
{
|
||||
var success = await _vm.LoadAsync(_appOptions);
|
||||
if (!success)
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
return;
|
||||
}
|
||||
AdjustToolbar();
|
||||
await ShowAlertsAsync();
|
||||
if (!_vm.EditMode && string.IsNullOrWhiteSpace(_vm.Cipher?.Name))
|
||||
{
|
||||
RequestFocus(_nameEntry);
|
||||
}
|
||||
});
|
||||
|
||||
_passwordPrompt.IsVisible = await _userVerificationService.HasMasterPasswordAsync();
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
protected override bool OnBackButtonPressed()
|
||||
{
|
||||
if (FromAutofillFramework)
|
||||
{
|
||||
Microsoft.Maui.Controls.Application.Current.MainPage = new TabsPage();
|
||||
return true;
|
||||
}
|
||||
return base.OnBackButtonPressed();
|
||||
}
|
||||
|
||||
private async void PasswordHistory_Tapped(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PushModalAsync(
|
||||
new Microsoft.Maui.Controls.NavigationPage(new PasswordHistoryPage(_vm.CipherId)));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Save_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await _vm.SubmitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void NewUri_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
_vm.AddUri();
|
||||
}
|
||||
|
||||
private void NewField_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
_vm.AddField();
|
||||
}
|
||||
|
||||
private async void Attachments_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
var page = new AttachmentsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Share_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
var page = new SharePage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Delete_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (await _vm.DeleteAsync())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void Collections_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
var page = new CollectionsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void ScanTotp_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
var page = new ScanPage(key =>
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(async () =>
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
await _vm.UpdateTotpKeyAsync(key);
|
||||
});
|
||||
});
|
||||
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void More_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var options = new List<string> { AppResources.Attachments };
|
||||
if (_vm.EditMode)
|
||||
{
|
||||
options.Add(_vm.Cipher.OrganizationId == null ? AppResources.MoveToOrganization : AppResources.Collections);
|
||||
}
|
||||
var selection = await DisplayActionSheet(AppResources.Options, AppResources.Cancel,
|
||||
(_vm.EditMode && !_vm.CloneMode) ? AppResources.Delete : null, options.ToArray());
|
||||
if (selection == AppResources.Delete)
|
||||
{
|
||||
if (await _vm.DeleteAsync())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
else if (selection == AppResources.Attachments)
|
||||
{
|
||||
var page = new AttachmentsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.Collections)
|
||||
{
|
||||
var page = new CollectionsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.MoveToOrganization)
|
||||
{
|
||||
var page = new SharePage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new Microsoft.Maui.Controls.NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowAlertsAsync()
|
||||
{
|
||||
if (!_vm.EditMode)
|
||||
{
|
||||
if (_vm.Cipher == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var addLoginShown = await _stateService.GetAddSitePromptShownAsync();
|
||||
if (_vm.Cipher.Type == CipherType.Login && !_fromAutofill && !addLoginShown.GetValueOrDefault())
|
||||
{
|
||||
await _stateService.SetAddSitePromptShownAsync(true);
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
if (_deviceActionService.SystemMajorVersion() < 12)
|
||||
{
|
||||
await DisplayAlert(AppResources.BitwardenAppExtension,
|
||||
AppResources.BitwardenAppExtensionAlert2, AppResources.Ok);
|
||||
}
|
||||
else
|
||||
{
|
||||
await DisplayAlert(AppResources.PasswordAutofill,
|
||||
AppResources.BitwardenAutofillAlert2, AppResources.Ok);
|
||||
}
|
||||
}
|
||||
else if (Device.RuntimePlatform == Device.Android &&
|
||||
!_autofillHandler.AutofillAccessibilityServiceRunning() &&
|
||||
!_autofillHandler.AutofillServiceEnabled())
|
||||
{
|
||||
await DisplayAlert(AppResources.BitwardenAutofillService,
|
||||
AppResources.BitwardenAutofillServiceAlert2, AppResources.Ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AdjustToolbar()
|
||||
{
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if ((_vm.EditMode || _vm.CloneMode) && Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
if (_vm.Cipher == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_vm.Cipher.OrganizationId == null)
|
||||
{
|
||||
if (ToolbarItems.Contains(_collectionsItem))
|
||||
{
|
||||
ToolbarItems.Remove(_collectionsItem);
|
||||
}
|
||||
if (!ToolbarItems.Contains(_shareItem) && !_vm.CloneMode)
|
||||
{
|
||||
ToolbarItems.Insert(2, _shareItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ToolbarItems.Contains(_shareItem))
|
||||
{
|
||||
ToolbarItems.Remove(_shareItem);
|
||||
}
|
||||
if (!ToolbarItems.Contains(_collectionsItem))
|
||||
{
|
||||
ToolbarItems.Insert(2, _collectionsItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PasswordPrompt_Toggled(object sender, ToggledEventArgs e)
|
||||
{
|
||||
_vm.Cipher.Reprompt = e.Value ? CipherRepromptType.Password : CipherRepromptType.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
917
src/Core/Pages/Vault/CipherAddEditPageViewModel.cs
Normal file
917
src/Core/Pages/Vault/CipherAddEditPageViewModel.cs
Normal file
@@ -0,0 +1,917 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Lists.ItemViewModels.CustomFields;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
using Bit.App.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class CipherAddEditPageViewModel : BaseCipherViewModel
|
||||
{
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IMessagingService _messagingService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ICustomFieldItemFactory _customFieldItemFactory;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
private readonly IAccountsManager _accountsManager;
|
||||
|
||||
private bool _showNotesSeparator;
|
||||
private bool _showPassword;
|
||||
private bool _showCardNumber;
|
||||
private bool _showCardCode;
|
||||
private int _typeSelectedIndex;
|
||||
private int _cardBrandSelectedIndex;
|
||||
private int _cardExpMonthSelectedIndex;
|
||||
private int _identityTitleSelectedIndex;
|
||||
private int _folderSelectedIndex;
|
||||
private int _ownershipSelectedIndex;
|
||||
private bool _hasCollections;
|
||||
private string _previousCipherId;
|
||||
private List<Core.Models.View.CollectionView> _writeableCollections;
|
||||
private bool _fromOtp;
|
||||
|
||||
protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => new string[]
|
||||
{
|
||||
nameof(IsLogin),
|
||||
nameof(IsIdentity),
|
||||
nameof(IsCard),
|
||||
nameof(IsSecureNote),
|
||||
nameof(ShowUris),
|
||||
nameof(ShowAttachments),
|
||||
nameof(ShowCollections),
|
||||
nameof(HasTotpValue)
|
||||
};
|
||||
|
||||
private List<KeyValuePair<UriMatchType?, string>> _matchDetectionOptions =
|
||||
new List<KeyValuePair<UriMatchType?, string>>
|
||||
{
|
||||
new KeyValuePair<UriMatchType?, string>(null, AppResources.Default),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.Domain, AppResources.BaseDomain),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.Host, AppResources.Host),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.StartsWith, AppResources.StartsWith),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.RegularExpression, AppResources.RegEx),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.Exact, AppResources.Exact),
|
||||
new KeyValuePair<UriMatchType?, string>(UriMatchType.Never, AppResources.Never)
|
||||
};
|
||||
|
||||
public CipherAddEditPageViewModel()
|
||||
{
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_folderService = ServiceContainer.Resolve<IFolderService>("folderService");
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
|
||||
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
|
||||
_collectionService = ServiceContainer.Resolve<ICollectionService>("collectionService");
|
||||
_eventService = ServiceContainer.Resolve<IEventService>("eventService");
|
||||
_policyService = ServiceContainer.Resolve<IPolicyService>("policyService");
|
||||
_customFieldItemFactory = ServiceContainer.Resolve<ICustomFieldItemFactory>("customFieldItemFactory");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
|
||||
|
||||
GeneratePasswordCommand = new Command(GeneratePassword);
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
ToggleCardNumberCommand = new Command(ToggleCardNumber);
|
||||
ToggleCardCodeCommand = new Command(ToggleCardCode);
|
||||
UriOptionsCommand = new Command<LoginUriView>(UriOptions);
|
||||
FieldOptionsCommand = new Command<ICustomFieldItemViewModel>(FieldOptions);
|
||||
PasswordPromptHelpCommand = new Command(PasswordPromptHelp);
|
||||
CopyCommand = new AsyncCommand(CopyTotpClipboardAsync, onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
GenerateUsernameCommand = new AsyncCommand(GenerateUsernameAsync, onException: ex => OnGenerateUsernameException(ex), allowsMultipleExecutions: false);
|
||||
Uris = new ExtendedObservableCollection<LoginUriView>();
|
||||
Fields = new ExtendedObservableCollection<ICustomFieldItemViewModel>();
|
||||
Collections = new ExtendedObservableCollection<CollectionViewModel>();
|
||||
AllowPersonal = true;
|
||||
|
||||
TypeOptions = new List<KeyValuePair<string, CipherType>>
|
||||
{
|
||||
new KeyValuePair<string, CipherType>(AppResources.TypeLogin, CipherType.Login),
|
||||
new KeyValuePair<string, CipherType>(AppResources.TypeCard, CipherType.Card),
|
||||
new KeyValuePair<string, CipherType>(AppResources.TypeIdentity, CipherType.Identity),
|
||||
new KeyValuePair<string, CipherType>(AppResources.TypeSecureNote, CipherType.SecureNote),
|
||||
};
|
||||
CardBrandOptions = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>($"-- {AppResources.Select} --", null),
|
||||
new KeyValuePair<string, string>("Visa", "Visa"),
|
||||
new KeyValuePair<string, string>("Mastercard", "Mastercard"),
|
||||
new KeyValuePair<string, string>("American Express", "Amex"),
|
||||
new KeyValuePair<string, string>("Discover", "Discover"),
|
||||
new KeyValuePair<string, string>("Diners Club", "Diners Club"),
|
||||
new KeyValuePair<string, string>("JCB", "JCB"),
|
||||
new KeyValuePair<string, string>("Maestro", "Maestro"),
|
||||
new KeyValuePair<string, string>("UnionPay", "UnionPay"),
|
||||
new KeyValuePair<string, string>("RuPay", "RuPay"),
|
||||
new KeyValuePair<string, string>(AppResources.Other, "Other")
|
||||
};
|
||||
CardExpMonthOptions = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>($"-- {AppResources.Select} --", null),
|
||||
new KeyValuePair<string, string>($"01 - {AppResources.January}", "1"),
|
||||
new KeyValuePair<string, string>($"02 - {AppResources.February}", "2"),
|
||||
new KeyValuePair<string, string>($"03 - {AppResources.March}", "3"),
|
||||
new KeyValuePair<string, string>($"04 - {AppResources.April}", "4"),
|
||||
new KeyValuePair<string, string>($"05 - {AppResources.May}", "5"),
|
||||
new KeyValuePair<string, string>($"06 - {AppResources.June}", "6"),
|
||||
new KeyValuePair<string, string>($"07 - {AppResources.July}", "7"),
|
||||
new KeyValuePair<string, string>($"08 - {AppResources.August}", "8"),
|
||||
new KeyValuePair<string, string>($"09 - {AppResources.September}", "9"),
|
||||
new KeyValuePair<string, string>($"10 - {AppResources.October}", "10"),
|
||||
new KeyValuePair<string, string>($"11 - {AppResources.November}", "11"),
|
||||
new KeyValuePair<string, string>($"12 - {AppResources.December}", "12")
|
||||
};
|
||||
IdentityTitleOptions = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>($"-- {AppResources.Select} --", null),
|
||||
new KeyValuePair<string, string>(AppResources.Mr, AppResources.Mr),
|
||||
new KeyValuePair<string, string>(AppResources.Mrs, AppResources.Mrs),
|
||||
new KeyValuePair<string, string>(AppResources.Ms, AppResources.Ms),
|
||||
new KeyValuePair<string, string>(AppResources.Mx, AppResources.Mx),
|
||||
new KeyValuePair<string, string>(AppResources.Dr, AppResources.Dr),
|
||||
};
|
||||
FolderOptions = new List<KeyValuePair<string, string>>();
|
||||
OwnershipOptions = new List<KeyValuePair<string, string>>();
|
||||
}
|
||||
|
||||
public Command GeneratePasswordCommand { get; set; }
|
||||
public Command TogglePasswordCommand { get; set; }
|
||||
public Command ToggleCardNumberCommand { get; set; }
|
||||
public Command ToggleCardCodeCommand { get; set; }
|
||||
public Command UriOptionsCommand { get; set; }
|
||||
public Command FieldOptionsCommand { get; set; }
|
||||
public Command PasswordPromptHelpCommand { get; set; }
|
||||
public AsyncCommand CopyCommand { get; set; }
|
||||
public AsyncCommand GenerateUsernameCommand { get; set; }
|
||||
public string CipherId { get; set; }
|
||||
public string OrganizationId { get; set; }
|
||||
public string FolderId { get; set; }
|
||||
public CipherType? Type { get; set; }
|
||||
public HashSet<string> CollectionIds { get; set; }
|
||||
public string DefaultName { get; set; }
|
||||
public string DefaultUri { get; set; }
|
||||
public List<KeyValuePair<string, CipherType>> TypeOptions { get; set; }
|
||||
public List<KeyValuePair<string, string>> CardBrandOptions { get; set; }
|
||||
public List<KeyValuePair<string, string>> CardExpMonthOptions { get; set; }
|
||||
public List<KeyValuePair<string, string>> IdentityTitleOptions { get; set; }
|
||||
public List<KeyValuePair<string, string>> FolderOptions { get; set; }
|
||||
public List<KeyValuePair<string, string>> OwnershipOptions { get; set; }
|
||||
public ExtendedObservableCollection<LoginUriView> Uris { get; set; }
|
||||
public ExtendedObservableCollection<ICustomFieldItemViewModel> Fields { get; set; }
|
||||
public ExtendedObservableCollection<CollectionViewModel> Collections { get; set; }
|
||||
|
||||
public int TypeSelectedIndex
|
||||
{
|
||||
get => _typeSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _typeSelectedIndex, value))
|
||||
{
|
||||
TypeChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public int CardBrandSelectedIndex
|
||||
{
|
||||
get => _cardBrandSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _cardBrandSelectedIndex, value))
|
||||
{
|
||||
CardBrandChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public int CardExpMonthSelectedIndex
|
||||
{
|
||||
get => _cardExpMonthSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _cardExpMonthSelectedIndex, value))
|
||||
{
|
||||
CardExpMonthChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public int IdentityTitleSelectedIndex
|
||||
{
|
||||
get => _identityTitleSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _identityTitleSelectedIndex, value))
|
||||
{
|
||||
IdentityTitleChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public int FolderSelectedIndex
|
||||
{
|
||||
get => _folderSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _folderSelectedIndex, value))
|
||||
{
|
||||
FolderChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public int OwnershipSelectedIndex
|
||||
{
|
||||
get => _ownershipSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _ownershipSelectedIndex, value))
|
||||
{
|
||||
OrganizationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public bool ShowNotesSeparator
|
||||
{
|
||||
get => _showNotesSeparator;
|
||||
set => SetProperty(ref _showNotesSeparator, value);
|
||||
}
|
||||
public bool ShowPassword
|
||||
{
|
||||
get => _showPassword;
|
||||
set => SetProperty(ref _showPassword, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowPasswordIcon),
|
||||
nameof(PasswordVisibilityAccessibilityText)
|
||||
});
|
||||
}
|
||||
public bool ShowCardNumber
|
||||
{
|
||||
get => _showCardNumber;
|
||||
set => SetProperty(ref _showCardNumber, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowCardNumberIcon)
|
||||
});
|
||||
}
|
||||
public bool ShowCardCode
|
||||
{
|
||||
get => _showCardCode;
|
||||
set => SetProperty(ref _showCardCode, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowCardCodeIcon)
|
||||
});
|
||||
}
|
||||
public bool HasCollections
|
||||
{
|
||||
get => _hasCollections;
|
||||
set => SetProperty(ref _hasCollections, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowCollections)
|
||||
});
|
||||
}
|
||||
public bool ShowCollections => (!EditMode || CloneMode) && Cipher.OrganizationId != null;
|
||||
public bool EditMode => !string.IsNullOrWhiteSpace(CipherId);
|
||||
public bool ShowOwnershipOptions => !EditMode || CloneMode;
|
||||
public bool OwnershipPolicyInEffect => ShowOwnershipOptions && !AllowPersonal;
|
||||
public bool CloneMode { get; set; }
|
||||
public CipherDetailsPage CipherDetailsPage { get; set; }
|
||||
public bool IsLogin => Cipher?.Type == CipherType.Login;
|
||||
public bool IsIdentity => Cipher?.Type == CipherType.Identity;
|
||||
public bool IsCard => Cipher?.Type == CipherType.Card;
|
||||
public bool IsSecureNote => Cipher?.Type == CipherType.SecureNote;
|
||||
public bool ShowUris => IsLogin && Cipher.Login.HasUris;
|
||||
public bool ShowAttachments => Cipher.HasAttachments;
|
||||
public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public string ShowCardNumberIcon => ShowCardNumber ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public string ShowCardCodeIcon => ShowCardCode ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public int PasswordFieldColSpan => Cipher.ViewPassword ? 1 : 4;
|
||||
public int TotpColumnSpan => Cipher.ViewPassword ? 1 : 2;
|
||||
public bool AllowPersonal { get; set; }
|
||||
public bool PasswordPrompt => Cipher.Reprompt != CipherRepromptType.None;
|
||||
public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow;
|
||||
public bool HasTotpValue => IsLogin && !string.IsNullOrEmpty(Cipher?.Login?.Totp);
|
||||
public string SetupTotpText => $"{BitwardenIcons.Camera} {AppResources.SetupTotp}";
|
||||
public bool ShowPasskeyInfo => Cipher?.HasFido2Key == true && !CloneMode;
|
||||
|
||||
public void Init()
|
||||
{
|
||||
PageTitle = EditMode && !CloneMode ? AppResources.EditItem : AppResources.AddItem;
|
||||
}
|
||||
|
||||
public async Task<bool> LoadAsync(AppOptions appOptions = null)
|
||||
{
|
||||
_fromOtp = appOptions?.OtpData != null;
|
||||
|
||||
var myEmail = await _stateService.GetEmailAsync();
|
||||
OwnershipOptions.Add(new KeyValuePair<string, string>(myEmail, null));
|
||||
var orgs = await _organizationService.GetAllAsync();
|
||||
foreach (var org in orgs.OrderBy(o => o.Name))
|
||||
{
|
||||
if (org.Enabled && org.Status == OrganizationUserStatusType.Confirmed)
|
||||
{
|
||||
OwnershipOptions.Add(new KeyValuePair<string, string>(org.Name, org.Id));
|
||||
}
|
||||
}
|
||||
|
||||
var personalOwnershipPolicyApplies = await _policyService.PolicyAppliesToUser(PolicyType.PersonalOwnership);
|
||||
if (personalOwnershipPolicyApplies && (!EditMode || CloneMode))
|
||||
{
|
||||
AllowPersonal = false;
|
||||
// Remove personal ownership
|
||||
OwnershipOptions.RemoveAt(0);
|
||||
}
|
||||
|
||||
var allCollections = await _collectionService.GetAllDecryptedAsync();
|
||||
_writeableCollections = allCollections.Where(c => !c.ReadOnly).ToList();
|
||||
if (CollectionIds?.Any() ?? false)
|
||||
{
|
||||
var colId = CollectionIds.First();
|
||||
var collection = _writeableCollections.FirstOrDefault(c => c.Id == colId);
|
||||
OrganizationId = collection?.OrganizationId;
|
||||
}
|
||||
var folders = await _folderService.GetAllDecryptedAsync();
|
||||
FolderOptions = folders.Select(f => new KeyValuePair<string, string>(f.Name, f.Id)).ToList();
|
||||
|
||||
if (Cipher == null)
|
||||
{
|
||||
if (EditMode)
|
||||
{
|
||||
var cipher = await _cipherService.GetAsync(CipherId);
|
||||
if (cipher == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Cipher = await cipher.DecryptAsync();
|
||||
if (CloneMode)
|
||||
{
|
||||
Cipher.Name += " - " + AppResources.Clone;
|
||||
// If not allowing personal ownership, update cipher's org Id to prompt downstream changes
|
||||
if (Cipher.OrganizationId == null && !AllowPersonal)
|
||||
{
|
||||
Cipher.OrganizationId = OrganizationId;
|
||||
}
|
||||
if (Cipher.Type == CipherType.Login)
|
||||
{
|
||||
// passkeys can't be cloned
|
||||
Cipher.Login.Fido2Keys = null;
|
||||
}
|
||||
}
|
||||
if (appOptions?.OtpData != null && Cipher.Type == CipherType.Login)
|
||||
{
|
||||
Cipher.Login.Totp = appOptions.OtpData.Value.Uri;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Cipher = new CipherView
|
||||
{
|
||||
Name = DefaultName,
|
||||
OrganizationId = OrganizationId,
|
||||
FolderId = FolderId,
|
||||
Type = Type.GetValueOrDefault(CipherType.Login),
|
||||
Login = new LoginView(),
|
||||
Card = new CardView(),
|
||||
Identity = new IdentityView(),
|
||||
SecureNote = new SecureNoteView()
|
||||
};
|
||||
Cipher.Login.Uris = new List<LoginUriView> { new LoginUriView { Uri = DefaultUri } };
|
||||
Cipher.SecureNote.Type = SecureNoteType.Generic;
|
||||
|
||||
if (appOptions != null)
|
||||
{
|
||||
Cipher.Type = appOptions.SaveType.GetValueOrDefault(Cipher.Type);
|
||||
Cipher.Login.Username = appOptions.SaveUsername;
|
||||
Cipher.Login.Password = appOptions.SavePassword;
|
||||
Cipher.Login.Totp = appOptions.OtpData?.Uri;
|
||||
Cipher.Card.Code = appOptions.SaveCardCode;
|
||||
if (int.TryParse(appOptions.SaveCardExpMonth, out int month) && month <= 12 && month >= 1)
|
||||
{
|
||||
Cipher.Card.ExpMonth = month.ToString();
|
||||
}
|
||||
Cipher.Card.ExpYear = appOptions.SaveCardExpYear;
|
||||
Cipher.Card.CardholderName = appOptions.SaveCardName;
|
||||
Cipher.Card.Number = appOptions.SaveCardNumber;
|
||||
}
|
||||
}
|
||||
|
||||
TypeSelectedIndex = TypeOptions.FindIndex(k => k.Value == Cipher.Type);
|
||||
FolderSelectedIndex = string.IsNullOrWhiteSpace(Cipher.FolderId) ? FolderOptions.Count - 1 :
|
||||
FolderOptions.FindIndex(k => k.Value == Cipher.FolderId);
|
||||
CardBrandSelectedIndex = string.IsNullOrWhiteSpace(Cipher.Card?.Brand) ? 0 :
|
||||
CardBrandOptions.FindIndex(k => k.Value == Cipher.Card.Brand);
|
||||
CardExpMonthSelectedIndex = string.IsNullOrWhiteSpace(Cipher.Card?.ExpMonth) ? 0 :
|
||||
CardExpMonthOptions.FindIndex(k => k.Value == Cipher.Card.ExpMonth);
|
||||
IdentityTitleSelectedIndex = string.IsNullOrWhiteSpace(Cipher.Identity?.Title) ? 0 :
|
||||
IdentityTitleOptions.FindIndex(k => k.Value == Cipher.Identity.Title);
|
||||
OwnershipSelectedIndex = string.IsNullOrWhiteSpace(Cipher.OrganizationId) ? 0 :
|
||||
OwnershipOptions.FindIndex(k => k.Value == Cipher.OrganizationId);
|
||||
|
||||
// If the selected organization is on Index 0 and we've removed the personal option, force refresh
|
||||
if (!AllowPersonal && OwnershipSelectedIndex == 0)
|
||||
{
|
||||
OrganizationChanged();
|
||||
}
|
||||
|
||||
if ((!EditMode || CloneMode) && (CollectionIds?.Any() ?? false))
|
||||
{
|
||||
foreach (var col in Collections)
|
||||
{
|
||||
col.Checked = CollectionIds.Contains(col.Collection.Id);
|
||||
}
|
||||
}
|
||||
if (Cipher.Login?.Uris != null)
|
||||
{
|
||||
Uris.ResetWithRange(Cipher.Login.Uris);
|
||||
}
|
||||
if (Cipher.Fields != null)
|
||||
{
|
||||
Fields.ResetWithRange(Cipher.Fields?.Select(f => _customFieldItemFactory.CreateCustomFieldItem(f, true, Cipher, null, null, FieldOptionsCommand)));
|
||||
}
|
||||
|
||||
if (appOptions?.OtpData != null)
|
||||
{
|
||||
_platformUtilsService.ShowToast(null, AppResources.AuthenticatorKey, AppResources.AuthenticatorKeyAdded);
|
||||
}
|
||||
}
|
||||
|
||||
if (EditMode && _previousCipherId != CipherId)
|
||||
{
|
||||
var task = _eventService.CollectAsync(EventType.Cipher_ClientViewed, CipherId);
|
||||
}
|
||||
_previousCipherId = CipherId;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SubmitAsync()
|
||||
{
|
||||
if (Cipher == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(Cipher.Name))
|
||||
{
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred,
|
||||
string.Format(AppResources.ValidationFieldRequired, AppResources.Name),
|
||||
AppResources.Ok);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!EditMode || CloneMode) && !AllowPersonal && string.IsNullOrWhiteSpace(Cipher.OrganizationId))
|
||||
{
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred,
|
||||
AppResources.PersonalOwnershipSubmitError, AppResources.Ok);
|
||||
return false;
|
||||
}
|
||||
|
||||
Cipher.Fields = Fields != null && Fields.Any() ?
|
||||
Fields.Where(f => f != null).Select(f => f.Field).ToList() : null;
|
||||
if (Cipher.Login != null)
|
||||
{
|
||||
Cipher.Login.Uris = Uris?.ToList();
|
||||
if ((!EditMode || CloneMode) && Cipher.Type == CipherType.Login && Cipher.Login.Uris != null &&
|
||||
Cipher.Login.Uris.Count == 1 && string.IsNullOrWhiteSpace(Cipher.Login.Uris[0].Uri))
|
||||
{
|
||||
Cipher.Login.Uris = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!EditMode || CloneMode) && Cipher.OrganizationId != null)
|
||||
{
|
||||
if (Collections == null || !Collections.Any(c => c != null && c.Checked))
|
||||
{
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.SelectOneCollection,
|
||||
AppResources.Ok);
|
||||
return false;
|
||||
}
|
||||
|
||||
Cipher.CollectionIds = Collections.Any() ?
|
||||
new HashSet<string>(Collections.Where(c => c != null && c.Checked && c.Collection?.Id != null)
|
||||
.Select(c => c.Collection.Id)) : null;
|
||||
}
|
||||
|
||||
if (CloneMode)
|
||||
{
|
||||
Cipher.Id = null;
|
||||
}
|
||||
var cipher = await _cipherService.EncryptAsync(Cipher);
|
||||
if (cipher == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
|
||||
await _cipherService.SaveWithServerAsync(cipher);
|
||||
Cipher.Id = cipher.Id;
|
||||
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
|
||||
_platformUtilsService.ShowToast("success", null,
|
||||
EditMode && !CloneMode ? AppResources.ItemUpdated : AppResources.NewItemCreated);
|
||||
_messagingService.Send(EditMode && !CloneMode ? "editedCipher" : "addedCipher", Cipher.Id);
|
||||
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
|
||||
if (Page is CipherAddEditPage page && page.FromAutofillFramework)
|
||||
{
|
||||
// Close and go back to app
|
||||
_autofillHandler.CloseAutofill();
|
||||
}
|
||||
else if (_fromOtp)
|
||||
{
|
||||
await _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CloneMode)
|
||||
{
|
||||
CipherDetailsPage?.UpdateCipherId(this.Cipher.Id);
|
||||
}
|
||||
// if the app is tombstoned then PopModalAsync would throw index out of bounds
|
||||
if (Page.Navigation?.ModalStack?.Count > 0)
|
||||
{
|
||||
await Page.Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (ApiException apiEx)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (apiEx?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(apiEx.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
catch (Exception genex)
|
||||
{
|
||||
_logger.Exception(genex);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync()
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
var confirmed = await _platformUtilsService.ShowDialogAsync(
|
||||
AppResources.DoYouReallyWantToSoftDeleteCipher,
|
||||
null, AppResources.Yes, AppResources.Cancel);
|
||||
if (!confirmed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.SoftDeleting);
|
||||
await _cipherService.SoftDeleteWithServerAsync(Cipher.Id);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.ItemSoftDeleted);
|
||||
_messagingService.Send("softDeletedCipher", Cipher);
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async void GeneratePassword()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Cipher?.Login?.Password))
|
||||
{
|
||||
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.PasswordOverrideAlert,
|
||||
null, AppResources.Yes, AppResources.No);
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
var page = new GeneratorPage(false, async (password) =>
|
||||
{
|
||||
Cipher.Login.Password = password;
|
||||
TriggerCipherChanged();
|
||||
await Page.Navigation.PopModalAsync();
|
||||
});
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
|
||||
public async Task GenerateUsernameAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Cipher?.Login?.Username)
|
||||
&& !await _platformUtilsService.ShowDialogAsync(AppResources.AreYouSureYouWantToOverwriteTheCurrentUsername, null, AppResources.Yes, AppResources.No))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var website = Cipher?.Login?.Uris?.FirstOrDefault()?.Host;
|
||||
|
||||
var page = new GeneratorPage(false, async (username) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Cipher.Login.Username = username;
|
||||
TriggerCipherChanged();
|
||||
await Page.Navigation.PopModalAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnGenerateUsernameException(ex);
|
||||
}
|
||||
}, isUsernameGenerator: true, emailWebsite: website, editMode: true);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
|
||||
public async void UriOptions(LoginUriView uri)
|
||||
{
|
||||
if (!(Page as CipherAddEditPage).DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var selection = await Page.DisplayActionSheet(AppResources.Options, AppResources.Cancel, null,
|
||||
AppResources.MatchDetection, AppResources.Remove);
|
||||
if (selection == AppResources.Remove)
|
||||
{
|
||||
Uris.Remove(uri);
|
||||
}
|
||||
else if (selection == AppResources.MatchDetection)
|
||||
{
|
||||
var options = _matchDetectionOptions.Select(o => o.Key == uri.Match ? $"✓ {o.Value}" : o.Value);
|
||||
var matchSelection = await Page.DisplayActionSheet(AppResources.URIMatchDetection,
|
||||
AppResources.Cancel, null, options.ToArray());
|
||||
if (matchSelection != null && matchSelection != AppResources.Cancel)
|
||||
{
|
||||
var matchSelectionClean = matchSelection.Replace("✓ ", string.Empty);
|
||||
uri.Match = _matchDetectionOptions.FirstOrDefault(o => o.Value == matchSelectionClean).Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddUri()
|
||||
{
|
||||
if (Cipher.Type != CipherType.Login)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Uris == null)
|
||||
{
|
||||
Uris = new ExtendedObservableCollection<LoginUriView>();
|
||||
}
|
||||
Uris.Add(new LoginUriView());
|
||||
}
|
||||
|
||||
public async void FieldOptions(ICustomFieldItemViewModel field)
|
||||
{
|
||||
if (!(Page as CipherAddEditPage).DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var selection = await Page.DisplayActionSheet(AppResources.Options, AppResources.Cancel, null,
|
||||
AppResources.Edit, AppResources.MoveUp, AppResources.MoveDown, AppResources.Remove);
|
||||
if (selection == AppResources.Remove)
|
||||
{
|
||||
Fields.Remove(field);
|
||||
}
|
||||
else if (selection == AppResources.Edit)
|
||||
{
|
||||
var name = await _deviceActionService.DisplayPromptAync(AppResources.CustomFieldName,
|
||||
null, field.Field.Name);
|
||||
field.Field.Name = name ?? field.Field.Name;
|
||||
field.TriggerFieldChanged();
|
||||
}
|
||||
else if (selection == AppResources.MoveUp)
|
||||
{
|
||||
var currentIndex = Fields.IndexOf(field);
|
||||
if (currentIndex > 0)
|
||||
{
|
||||
Fields.Move(currentIndex, currentIndex - 1);
|
||||
}
|
||||
}
|
||||
else if (selection == AppResources.MoveDown)
|
||||
{
|
||||
var currentIndex = Fields.IndexOf(field);
|
||||
if (currentIndex < Fields.Count - 1)
|
||||
{
|
||||
Fields.Move(currentIndex, currentIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void AddField()
|
||||
{
|
||||
var fieldTypeOptions = new List<KeyValuePair<FieldType, string>>
|
||||
{
|
||||
new KeyValuePair<FieldType, string>(FieldType.Text, AppResources.FieldTypeText),
|
||||
new KeyValuePair<FieldType, string>(FieldType.Hidden, AppResources.FieldTypeHidden),
|
||||
new KeyValuePair<FieldType, string>(FieldType.Boolean, AppResources.FieldTypeBoolean),
|
||||
};
|
||||
|
||||
if (Cipher.LinkedFieldOptions != null)
|
||||
{
|
||||
fieldTypeOptions.Add(new KeyValuePair<FieldType, string>(FieldType.Linked, AppResources.FieldTypeLinked));
|
||||
}
|
||||
|
||||
var typeSelection = await Page.DisplayActionSheet(AppResources.SelectTypeField, AppResources.Cancel, null,
|
||||
fieldTypeOptions.Select(f => f.Value).ToArray());
|
||||
if (typeSelection != null && typeSelection != AppResources.Cancel)
|
||||
{
|
||||
var name = await _deviceActionService.DisplayPromptAync(AppResources.CustomFieldName);
|
||||
if (name == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Fields == null)
|
||||
{
|
||||
Fields = new ExtendedObservableCollection<ICustomFieldItemViewModel>();
|
||||
}
|
||||
var type = fieldTypeOptions.FirstOrDefault(f => f.Value == typeSelection).Key;
|
||||
Fields.Add(_customFieldItemFactory.CreateCustomFieldItem(new FieldView
|
||||
{
|
||||
Type = type,
|
||||
Name = string.IsNullOrWhiteSpace(name) ? null : name,
|
||||
NewField = true,
|
||||
}, true, Cipher, null, null, FieldOptionsCommand));
|
||||
}
|
||||
}
|
||||
|
||||
public void TogglePassword()
|
||||
{
|
||||
ShowPassword = !ShowPassword;
|
||||
if (EditMode && ShowPassword)
|
||||
{
|
||||
var task = _eventService.CollectAsync(EventType.Cipher_ClientToggledPasswordVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public void ToggleCardNumber()
|
||||
{
|
||||
ShowCardNumber = !ShowCardNumber;
|
||||
if (EditMode && ShowCardNumber)
|
||||
{
|
||||
var task = _eventService.CollectAsync(
|
||||
Core.Enums.EventType.Cipher_ClientToggledCardNumberVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public void ToggleCardCode()
|
||||
{
|
||||
ShowCardCode = !ShowCardCode;
|
||||
if (EditMode && ShowCardCode)
|
||||
{
|
||||
var task = _eventService.CollectAsync(EventType.Cipher_ClientToggledCardCodeVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateTotpKeyAsync(string key)
|
||||
{
|
||||
if (Cipher?.Login != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
Cipher.Login.Totp = key;
|
||||
TriggerCipherChanged();
|
||||
_platformUtilsService.ShowToast("info", null, AppResources.AuthenticatorKeyAdded);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.AuthenticatorKeyReadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void PasswordPromptHelp()
|
||||
{
|
||||
_platformUtilsService.LaunchUri("https://bitwarden.com/help/managing-items/#protect-individual-items");
|
||||
}
|
||||
|
||||
private void TypeChanged()
|
||||
{
|
||||
if (Cipher != null && TypeSelectedIndex > -1)
|
||||
{
|
||||
Cipher.Type = TypeOptions[TypeSelectedIndex].Value;
|
||||
TriggerCipherChanged();
|
||||
|
||||
// Linked Custom Fields only apply to a specific item type
|
||||
foreach (var field in Fields.OfType<LinkedCustomFieldItemViewModel>().ToList())
|
||||
{
|
||||
Fields.Remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CardBrandChanged()
|
||||
{
|
||||
if (Cipher?.Card != null && CardBrandSelectedIndex > -1)
|
||||
{
|
||||
Cipher.Card.Brand = CardBrandOptions[CardBrandSelectedIndex].Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void CardExpMonthChanged()
|
||||
{
|
||||
if (Cipher?.Card != null && CardExpMonthSelectedIndex > -1)
|
||||
{
|
||||
Cipher.Card.ExpMonth = CardExpMonthOptions[CardExpMonthSelectedIndex].Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void IdentityTitleChanged()
|
||||
{
|
||||
if (Cipher?.Identity != null && IdentityTitleSelectedIndex > -1)
|
||||
{
|
||||
Cipher.Identity.Title = IdentityTitleOptions[IdentityTitleSelectedIndex].Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void FolderChanged()
|
||||
{
|
||||
if (Cipher != null && FolderSelectedIndex > -1)
|
||||
{
|
||||
Cipher.FolderId = FolderOptions[FolderSelectedIndex].Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void OrganizationChanged()
|
||||
{
|
||||
if (Cipher != null && OwnershipSelectedIndex > -1)
|
||||
{
|
||||
Cipher.OrganizationId = OwnershipOptions[OwnershipSelectedIndex].Value;
|
||||
TriggerCipherChanged();
|
||||
}
|
||||
if (Cipher.OrganizationId != null)
|
||||
{
|
||||
var cols = _writeableCollections.Where(c => c.OrganizationId == Cipher.OrganizationId)
|
||||
.Select(c => new CollectionViewModel { Collection = c }).ToList();
|
||||
Collections.ResetWithRange(cols);
|
||||
}
|
||||
else
|
||||
{
|
||||
Collections.ResetWithRange(new List<CollectionViewModel>());
|
||||
}
|
||||
HasCollections = Collections.Any();
|
||||
}
|
||||
|
||||
private void TriggerCipherChanged()
|
||||
{
|
||||
TriggerPropertyChanged(nameof(Cipher), AdditionalPropertiesToRaiseOnCipherChanged);
|
||||
}
|
||||
|
||||
private async Task CopyTotpClipboardAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _clipboardService.CopyTextAsync(Cipher.Login.Totp);
|
||||
_platformUtilsService.ShowToast("info", null, string.Format(AppResources.ValueHasBeenCopied, AppResources.AuthenticatorKeyScanner));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Exception(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnGenerateUsernameException(Exception ex)
|
||||
{
|
||||
_logger.Exception(ex);
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
765
src/Core/Pages/Vault/CipherDetailsPage.xaml
Normal file
765
src/Core/Pages/Vault/CipherDetailsPage.xaml
Normal file
@@ -0,0 +1,765 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.CipherDetailsPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:effects="clr-namespace:Bit.App.Effects"
|
||||
xmlns:views="clr-namespace:Bit.Core.Models.View"
|
||||
xmlns:dts="clr-namespace:Bit.App.Lists.DataTemplateSelectors"
|
||||
xmlns:il="clr-namespace:Bit.App.Lists.ItemLayouts.CustomFields"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:CipherDetailsPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
<ContentPage.BindingContext>
|
||||
<pages:CipherDetailsPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:StringHasValueConverter x:Key="stringHasValue" />
|
||||
<u:IsNotNullConverter x:Key="notNull" />
|
||||
<ToolbarItem Text="{u:I18n Collections}"
|
||||
x:Key="collectionsItem"
|
||||
x:Name="_collectionsItem"
|
||||
Clicked="Collections_Clicked"
|
||||
Order="Secondary" />
|
||||
<ToolbarItem Text="{u:I18n MoveToOrganization}"
|
||||
x:Key="shareItem"
|
||||
x:Name="_shareItem"
|
||||
Clicked="Share_Clicked"
|
||||
Order="Secondary" />
|
||||
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1"
|
||||
x:Name="_closeItem" x:Key="closeItem" />
|
||||
<ToolbarItem Clicked="EditToolbarItem_Clicked" Order="Primary"
|
||||
x:Name="_editItem" x:Key="editItem" />
|
||||
<ToolbarItem IconImageSource="more_vert.png" Clicked="More_Clicked" Order="Primary"
|
||||
x:Name="_moreItem" x:Key="moreItem"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Options}" />
|
||||
<ToolbarItem Text="{u:I18n Attachments}" Clicked="Attachments_Clicked" Order="Secondary"
|
||||
x:Name="_attachmentsItem" x:Key="attachmentsItem" />
|
||||
<ToolbarItem Text="{u:I18n Delete}" Clicked="Delete_Clicked" Order="Secondary" IsDestructive="True"
|
||||
x:Name="_deleteItem" x:Key="deleteItem" />
|
||||
<ToolbarItem Text="{u:I18n Clone}" Command="{Binding CloneCommand}" Order="Secondary"
|
||||
x:Name="_cloneItem" x:Key="cloneItem" />
|
||||
|
||||
<DataTemplate x:Key="TextCustomFieldDataTemplate">
|
||||
<il:TextCustomFieldItemLayout AutomationId="TextCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="BooleanCustomFieldDataTemplate">
|
||||
<il:BooleanCustomFieldItemLayout AutomationId="BooleanCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="HiddenCustomFieldDataTemplate">
|
||||
<il:HiddenCustomFieldItemLayout AutomationId="HiddenCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="LinkedCustomFieldDataTemplate">
|
||||
<il:LinkedCustomFieldItemLayout AutomationId="LinkedCustomFieldItem" />
|
||||
</DataTemplate>
|
||||
|
||||
<dts:CustomFieldItemTemplateSelector x:Key="CustomFieldItemTemplateSelector"
|
||||
TextTemplate="{StaticResource TextCustomFieldDataTemplate}"
|
||||
BooleanTemplate="{StaticResource BooleanCustomFieldDataTemplate}"
|
||||
HiddenTemplate="{StaticResource HiddenCustomFieldDataTemplate}"
|
||||
LinkedTemplate="{StaticResource LinkedCustomFieldDataTemplate}"/>
|
||||
|
||||
<ScrollView x:Key="scrollView" x:Name="_scrollView">
|
||||
<StackLayout Spacing="20" x:Name="_mainLayout">
|
||||
<StackLayout StyleClass="box" AutomationId="ItemInformationSection">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n ItemInformation, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row" AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Name}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Name, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
<StackLayout IsVisible="{Binding IsLogin}" Spacing="0" Padding="0">
|
||||
<Grid StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Login.Username, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n Username}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Login.Username, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
CommandParameter="LoginUsername"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyUsername}"
|
||||
AutomationId="CopyValueButton" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Login.Username, Converter={StaticResource stringHasValue}}" />
|
||||
<Grid StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Login.Password, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n Password}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemName" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding Cipher.Login.MaskedPassword, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding ShowPassword, Converter={StaticResource inverseBool}}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding ColoredPassword, Mode=OneWay}"
|
||||
StyleClass="box-value, text-html"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
LineBreakMode="CharacterWrap"
|
||||
IsVisible="{Binding ShowPassword}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.CheckCircle}}"
|
||||
Command="{Binding CheckPasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CheckPassword}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="CheckPasswordButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowPasswordIcon}"
|
||||
Command="{Binding TogglePasswordCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationProperties.HelpText="{Binding PasswordVisibilityAccessibilityText}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="ShowValueButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
CommandParameter="LoginPassword"
|
||||
Grid.Row="0"
|
||||
Grid.Column="3"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyPassword}"
|
||||
IsVisible="{Binding Cipher.ViewPassword}"
|
||||
AutomationId="CopyValueButton" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Login.Password, Converter={StaticResource stringHasValue}}" />
|
||||
<Label
|
||||
Text="{u:I18n Passkey}"
|
||||
StyleClass="box-label"
|
||||
Margin="0,10,0,0"
|
||||
IsVisible="{Binding Cipher.Login.MainFido2Key, Converter={StaticResource notNull}}"/>
|
||||
<Entry
|
||||
Text="{Binding CreationDate}"
|
||||
IsEnabled="False"
|
||||
StyleClass="box-value,text-muted"
|
||||
IsVisible="{Binding Cipher.Login.MainFido2Key, Converter={StaticResource notNull}}" />
|
||||
<Grid StyleClass="box-row"
|
||||
IsVisible="{Binding ShowTotp}"
|
||||
AutomationId="ItemRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="40" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n VerificationCodeTotp}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemName" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding TotpCodeFormatted, Mode=OneWay}"
|
||||
IsVisible="{Binding ShowUpgradePremiumTotpText, Converter={StaticResource inverseBool}}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
VerticalTextAlignment="Start"
|
||||
VerticalOptions="Start"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:CircularProgressbarView
|
||||
Progress="{Binding TotpProgress}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
VerticalOptions="FillAndExpand"
|
||||
AutomationId="LoginTotpProgressBar" />
|
||||
<Label
|
||||
Text="{Binding TotpSec, Mode=OneWay}"
|
||||
Style="{DynamicResource textTotp}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
StyleClass="text-sm"
|
||||
VerticalTextAlignment="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
VerticalOptions="FillAndExpand" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
IsVisible="{Binding CanAccessPremium}"
|
||||
CommandParameter="LoginTotp"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyTotp}"
|
||||
AutomationId="CopyValueButton" />
|
||||
<Label
|
||||
Text="{u:I18n PremiumSubscriptionRequired}"
|
||||
StyleClass="box-footer-label"
|
||||
IsVisible="{Binding ShowUpgradePremiumTotpText}"
|
||||
Margin="0,5,0,2"
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
AutomationId="ShowUpgradePremiumTotpLabel" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator" IsVisible="{Binding ShowTotp}" />
|
||||
</StackLayout>
|
||||
<StackLayout IsVisible="{Binding IsCard}" Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Card.CardholderName, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n CardholderName}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Card.CardholderName, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Card.CardholderName, Converter={StaticResource stringHasValue}}" />
|
||||
<Grid StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Card.Number, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n Number}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemName" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding Cipher.Card.MaskedNumber, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding ShowCardNumber, Converter={StaticResource inverseBool}}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding Cipher.Card.Number, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding ShowCardNumber}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowCardNumberIcon}"
|
||||
Command="{Binding ToggleCardNumberCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationId="ShowValueButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
CommandParameter="CardNumber"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyNumber}"
|
||||
AutomationId="CopyValueButton" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Card.Number, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Card.Brand, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Brand}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Card.Brand, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Card.Brand, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Card.Expiration, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Expiration}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Card.Expiration, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Card.Expiration, Converter={StaticResource stringHasValue}}" />
|
||||
<Grid StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Card.Code, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n SecurityCode}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
AutomationId="ItemName" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding Cipher.Card.MaskedCode, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding ShowCardCode, Converter={StaticResource inverseBool}}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:MonoLabel
|
||||
Text="{Binding Cipher.Card.Code, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding ShowCardCode}"
|
||||
AutomationId="ItemValue" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding ShowCardCodeIcon}"
|
||||
Command="{Binding ToggleCardCodeCommand}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n ToggleVisibility}"
|
||||
AutomationId="ShowValueButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding CopyCommand}"
|
||||
CommandParameter="CardCode"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopySecurityCode}"
|
||||
AutomationId="CopyValueButton" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Card.Code, Converter={StaticResource stringHasValue}}" />
|
||||
</StackLayout>
|
||||
<StackLayout IsVisible="{Binding IsIdentity}" Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.FullName, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n IdentityName}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.FullName, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.FullName, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.Username, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Username}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Username, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.Username, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.Company, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Company}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Company, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.Company, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.SSN, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n SSN}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.SSN, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.SSN, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.PassportNumber, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n PassportNumber}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.PassportNumber, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.PassportNumber, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.LicenseNumber, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n LicenseNumber}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.LicenseNumber, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.LicenseNumber, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.Email, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Email}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Email, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.Email, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding Cipher.Identity.Phone, Converter={StaticResource stringHasValue}}"
|
||||
AutomationId="ItemRow" >
|
||||
<Label
|
||||
Text="{u:I18n Phone}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Phone, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="ItemValue" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator"
|
||||
IsVisible="{Binding Cipher.Identity.Phone, Converter={StaticResource stringHasValue}}" />
|
||||
<StackLayout StyleClass="box-row" IsVisible="{Binding ShowIdentityAddress}"
|
||||
AutomationId="ItemRow">
|
||||
<Label
|
||||
Text="{u:I18n Address}"
|
||||
StyleClass="box-label"
|
||||
AutomationId="ItemName" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Address1, Mode=OneWay}"
|
||||
IsVisible="{Binding Cipher.Identity.Address1, Converter={StaticResource stringHasValue}}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityAddressOneLabel" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Address2, Mode=OneWay}"
|
||||
IsVisible="{Binding Cipher.Identity.Address2, Converter={StaticResource stringHasValue}}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityAddressTwoLabel" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Address3, Mode=OneWay}"
|
||||
IsVisible="{Binding Cipher.Identity.Address3, Converter={StaticResource stringHasValue}}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityAddressThreeLabel" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.FullAddressPart2, Mode=OneWay}"
|
||||
IsVisible="{Binding Cipher.Identity.FullAddressPart2, Converter={StaticResource stringHasValue}}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityFullAddressPartTwoLabel" />
|
||||
<Label
|
||||
Text="{Binding Cipher.Identity.Country, Mode=OneWay}"
|
||||
IsVisible="{Binding Cipher.Identity.Country, Converter={StaticResource stringHasValue}}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="IdentityCountryLabel" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" IsVisible="{Binding ShowIdentityAddress}" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding ShowUris}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n URIs, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Cipher.Login.Uris}" AutomationId="CipherUriContainer">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:LoginUriView">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<Grid StyleClass="box-row" AutomationId="UriRow">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label
|
||||
Text="{u:I18n URI}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding IsWebsite, Mode=OneWay, Converter={StaticResource inverseBool}}" />
|
||||
<Label
|
||||
Text="{u:I18n Website}"
|
||||
StyleClass="box-label"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
IsVisible="{Binding IsWebsite, Mode=OneWay}" />
|
||||
<Label
|
||||
Text="{Binding HostOrUri, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
AutomationId="UriValue" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.ShareSquare}}"
|
||||
Command="{Binding BindingContext.LaunchUriCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
IsVisible="{Binding CanLaunch, Mode=OneWay}"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Launch}"
|
||||
AutomationId="LaunchUriButton" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding BindingContext.CopyUriCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Copy}"
|
||||
AutomationId="CopyUriButton" />
|
||||
</Grid>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box"
|
||||
IsVisible="{Binding Cipher.Notes, Converter={StaticResource stringHasValue}}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Notes, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row" AutomationId="NotesRow">
|
||||
<controls:SelectableLabel
|
||||
Text="{Binding Cipher.Notes, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
AutomationId="CipherNotesLabel" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding Cipher.HasFields}" AutomationId="CustomFieldsContainer">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n CustomFields, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout
|
||||
Spacing="0"
|
||||
BindableLayout.ItemsSource="{Binding Fields}"
|
||||
BindableLayout.ItemTemplateSelector="{StaticResource CustomFieldItemTemplateSelector}" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box" IsVisible="{Binding ShowAttachments}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Attachments, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Cipher.Attachments}" AutomationId="CipherAttachmentsContainer">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:AttachmentView">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<StackLayout Orientation="Horizontal" StyleClass="box-row" Spacing="10" AutomationId="CipherAttachment">
|
||||
<Label
|
||||
Text="{Binding FileName, Mode=OneWay}"
|
||||
StyleClass="box-value"
|
||||
VerticalTextAlignment="Center"
|
||||
HorizontalOptions="StartAndExpand"
|
||||
AutomationId="CipherAttachmentFileNameLabel" />
|
||||
<Label
|
||||
Text="{Binding SizeName, Mode=OneWay}"
|
||||
StyleClass="box-sub-label"
|
||||
HorizontalTextAlignment="End"
|
||||
VerticalTextAlignment="Center"
|
||||
AutomationId="CipherAttachmentFileSizeLabel" />
|
||||
<controls:IconButton
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Download}}"
|
||||
Command="{Binding BindingContext.DownloadAttachmentCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
VerticalOptions="Center"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Download}"
|
||||
AutomationId="CipherAttachmentDownloadButton" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-bottom">
|
||||
<Label FormattedText="{Binding UpdatedText}"
|
||||
StyleClass="box-footer-label"
|
||||
AutomationId="CipherUpdatedDateLabel" />
|
||||
<Label FormattedText="{Binding PasswordUpdatedText}"
|
||||
StyleClass="box-footer-label"
|
||||
IsVisible="{Binding Cipher.PasswordRevisionDisplayDate, Converter={StaticResource notNull}}"
|
||||
AutomationId="CipherUpdatedPasswordDateLabel">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="PasswordHistory_Tapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
<Label FormattedText="{Binding PasswordHistoryText}"
|
||||
StyleClass="box-footer-label"
|
||||
IsVisible="{Binding Cipher.HasPasswordHistory}"
|
||||
AutomationId="CipherPasswordHistoryLabel">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="PasswordHistory_Tapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<AbsoluteLayout
|
||||
x:Name="_absLayout"
|
||||
VerticalOptions="FillAndExpand"
|
||||
HorizontalOptions="FillAndExpand">
|
||||
<ContentView
|
||||
x:Name="_mainContent"
|
||||
AbsoluteLayout.LayoutFlags="All"
|
||||
AbsoluteLayout.LayoutBounds="0, 0, 1, 1">
|
||||
</ContentView>
|
||||
<Button
|
||||
x:Name="_fab"
|
||||
ImageSource="pencil.png"
|
||||
Clicked="EditButton_Clicked"
|
||||
Style="{StaticResource btn-fab}"
|
||||
AbsoluteLayout.LayoutFlags="PositionProportional"
|
||||
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n EditItem}"
|
||||
AutomationId="CipherEditButton"
|
||||
IsVisible="{Binding CanEdit}">
|
||||
<Button.Effects>
|
||||
<effects:FabShadowEffect />
|
||||
</Button.Effects>
|
||||
</Button>
|
||||
</AbsoluteLayout>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
328
src/Core/Pages/Vault/CipherDetailsPage.xaml.cs
Normal file
328
src/Core/Pages/Vault/CipherDetailsPage.xaml.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class CipherDetailsPage : BaseContentPage
|
||||
{
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
private readonly ISyncService _syncService;
|
||||
private CipherDetailsPageViewModel _vm;
|
||||
|
||||
public CipherDetailsPage(string cipherId)
|
||||
{
|
||||
InitializeComponent();
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
|
||||
_vm = BindingContext as CipherDetailsPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
SetActivityIndicator(_mainContent);
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
_absLayout.Children.Remove(_fab);
|
||||
ToolbarItems.Add(_closeItem);
|
||||
ToolbarItems.Add(_editItem);
|
||||
ToolbarItems.Add(_moreItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
_mainLayout.Padding = new Thickness(0, 0, 0, 75);
|
||||
ToolbarItems.Add(_attachmentsItem);
|
||||
ToolbarItems.Add(_deleteItem);
|
||||
}
|
||||
}
|
||||
|
||||
public CipherDetailsPageViewModel ViewModel => _vm;
|
||||
|
||||
public void UpdateCipherId(string cipherId)
|
||||
{
|
||||
_vm.CipherId = cipherId;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_syncService.SyncInProgress)
|
||||
{
|
||||
IsBusy = true;
|
||||
}
|
||||
|
||||
_broadcasterService.Subscribe(nameof(CipherDetailsPage), async (message) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message.Command == "syncStarted")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() => IsBusy = true);
|
||||
}
|
||||
else if (message.Command == "syncCompleted")
|
||||
{
|
||||
await Task.Delay(500);
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsBusy = false;
|
||||
if (message.Data is Dictionary<string, object> data && data.ContainsKey("successfully"))
|
||||
{
|
||||
var success = data["successfully"] as bool?;
|
||||
if (success.GetValueOrDefault())
|
||||
{
|
||||
var task = _vm.LoadAsync(() => AdjustToolbar());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (message.Command == "selectSaveFileResult")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
var data = message.Data as Tuple<string, string>;
|
||||
if (data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_vm.SaveFileSelected(data.Item1, data.Item2);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
});
|
||||
await LoadOnAppearedAsync(_scrollView, true, async () =>
|
||||
{
|
||||
var success = await _vm.LoadAsync(() => AdjustToolbar());
|
||||
if (!success)
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}, _mainContent);
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
IsBusy = false;
|
||||
_vm.StopCiphersTotpTick().FireAndForget();
|
||||
_broadcasterService.Unsubscribe(nameof(CipherDetailsPage));
|
||||
}
|
||||
|
||||
private async void PasswordHistory_Tapped(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PushModalAsync(new NavigationPage(new PasswordHistoryPage(_vm.CipherId)));
|
||||
}
|
||||
}
|
||||
|
||||
private async void EditToolbarItem_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (_vm.IsDeleted)
|
||||
{
|
||||
if (await _vm.RestoreAsync())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
await Navigation.PushModalAsync(new NavigationPage(new CipherAddEditPage(_vm.CipherId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EditButton_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
EditToolbarItem_Clicked(sender, e);
|
||||
}
|
||||
|
||||
private async void Attachments_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var page = new AttachmentsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Share_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var page = new SharePage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Delete_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (await _vm.DeleteAsync())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void Collections_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var page = new CollectionsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void More_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new List<string> { AppResources.Attachments };
|
||||
if (_vm.Cipher.OrganizationId == null)
|
||||
{
|
||||
if (_vm.CanClone)
|
||||
{
|
||||
options.Add(AppResources.Clone);
|
||||
}
|
||||
|
||||
options.Add(AppResources.MoveToOrganization);
|
||||
}
|
||||
else
|
||||
{
|
||||
options.Add(AppResources.Collections);
|
||||
}
|
||||
|
||||
var selection = await DisplayActionSheet(AppResources.Options, AppResources.Cancel,
|
||||
AppResources.Delete, options.ToArray());
|
||||
|
||||
if (!await _vm.PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection == AppResources.Delete)
|
||||
{
|
||||
if (await _vm.DeleteAsync())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
else if (selection == AppResources.Attachments)
|
||||
{
|
||||
var page = new AttachmentsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.Collections)
|
||||
{
|
||||
var page = new CollectionsPage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.MoveToOrganization)
|
||||
{
|
||||
var page = new SharePage(_vm.CipherId);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.Clone)
|
||||
{
|
||||
_vm.CloneCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void AdjustToolbar()
|
||||
{
|
||||
if (_vm.Cipher == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_editItem.Text = _vm.Cipher.IsDeleted ? AppResources.Restore :
|
||||
AppResources.Edit;
|
||||
if (_vm.Cipher.IsDeleted)
|
||||
{
|
||||
_absLayout.Children.Remove(_fab);
|
||||
}
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform != Device.Android)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_vm.Cipher.OrganizationId == null)
|
||||
{
|
||||
if (ToolbarItems.Contains(_collectionsItem))
|
||||
{
|
||||
ToolbarItems.Remove(_collectionsItem);
|
||||
}
|
||||
if (_vm.CanClone && !ToolbarItems.Contains(_cloneItem))
|
||||
{
|
||||
ToolbarItems.Insert(1, _cloneItem);
|
||||
}
|
||||
if (!ToolbarItems.Contains(_shareItem))
|
||||
{
|
||||
ToolbarItems.Insert(_vm.CanClone ? 2 : 1, _shareItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ToolbarItems.Contains(_cloneItem))
|
||||
{
|
||||
ToolbarItems.Remove(_cloneItem);
|
||||
}
|
||||
if (ToolbarItems.Contains(_shareItem))
|
||||
{
|
||||
ToolbarItems.Remove(_shareItem);
|
||||
}
|
||||
if (!ToolbarItems.Contains(_collectionsItem))
|
||||
{
|
||||
ToolbarItems.Insert(1, _collectionsItem);
|
||||
}
|
||||
}
|
||||
if (_vm.Cipher.IsDeleted && !ToolbarItems.Contains(_editItem))
|
||||
{
|
||||
ToolbarItems.Insert(1, _editItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
716
src/Core/Pages/Vault/CipherDetailsPageViewModel.cs
Normal file
716
src/Core/Pages/Vault/CipherDetailsPageViewModel.cs
Normal file
@@ -0,0 +1,716 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Lists.ItemViewModels.CustomFields;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class CipherDetailsPageViewModel : BaseCipherViewModel, IPasswordPromptable
|
||||
{
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IMessagingService _messagingService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IPasswordRepromptService _passwordRepromptService;
|
||||
private readonly ILocalizeService _localizeService;
|
||||
private readonly ICustomFieldItemFactory _customFieldItemFactory;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
|
||||
private List<ICustomFieldItemViewModel> _fields;
|
||||
private bool _canAccessPremium;
|
||||
private bool _showPassword;
|
||||
private bool _showCardNumber;
|
||||
private bool _showCardCode;
|
||||
private string _totpCode;
|
||||
private string _totpCodeFormatted;
|
||||
private string _totpSec;
|
||||
private double _totpInterval = Constants.TotpDefaultTimer;
|
||||
private bool _totpLow;
|
||||
private string _previousCipherId;
|
||||
private byte[] _attachmentData;
|
||||
private string _attachmentFilename;
|
||||
private bool _passwordReprompted;
|
||||
private TotpHelper _totpTickHelper;
|
||||
private CancellationTokenSource _totpTickCancellationToken;
|
||||
private Task _totpTickTask;
|
||||
|
||||
public CipherDetailsPageViewModel()
|
||||
{
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_auditService = ServiceContainer.Resolve<IAuditService>("auditService");
|
||||
_totpService = ServiceContainer.Resolve<ITotpService>("totpService");
|
||||
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
|
||||
_eventService = ServiceContainer.Resolve<IEventService>("eventService");
|
||||
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
|
||||
_localizeService = ServiceContainer.Resolve<ILocalizeService>("localizeService");
|
||||
_customFieldItemFactory = ServiceContainer.Resolve<ICustomFieldItemFactory>("customFieldItemFactory");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
|
||||
CopyCommand = new AsyncCommand<string>((id) => CopyAsync(id, null), onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
CopyUriCommand = new AsyncCommand<LoginUriView>(uriView => CopyAsync("LoginUri", uriView.Uri), onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
CopyFieldCommand = new AsyncCommand<FieldView>(field => CopyAsync(field.Type == FieldType.Hidden ? "H_FieldValue" : "FieldValue", field.Value), onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
LaunchUriCommand = new Command<ILaunchableView>(LaunchUri);
|
||||
CloneCommand = new AsyncCommand(CloneAsync, onException: ex => HandleException(ex), allowsMultipleExecutions: false);
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
ToggleCardNumberCommand = new Command(ToggleCardNumber);
|
||||
ToggleCardCodeCommand = new Command(ToggleCardCode);
|
||||
DownloadAttachmentCommand = new AsyncCommand<AttachmentView>(DownloadAttachmentAsync, allowsMultipleExecutions: false);
|
||||
|
||||
PageTitle = AppResources.ViewItem;
|
||||
}
|
||||
|
||||
public ICommand CopyCommand { get; set; }
|
||||
public ICommand CopyUriCommand { get; set; }
|
||||
public ICommand CopyFieldCommand { get; set; }
|
||||
public Command LaunchUriCommand { get; set; }
|
||||
public ICommand CloneCommand { get; set; }
|
||||
public Command TogglePasswordCommand { get; set; }
|
||||
public Command ToggleCardNumberCommand { get; set; }
|
||||
public Command ToggleCardCodeCommand { get; set; }
|
||||
public AsyncCommand<AttachmentView> DownloadAttachmentCommand { get; set; }
|
||||
public string CipherId { get; set; }
|
||||
protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => 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),
|
||||
nameof(IsDeleted),
|
||||
nameof(CanEdit),
|
||||
nameof(ShowUpgradePremiumTotpText)
|
||||
};
|
||||
public List<ICustomFieldItemViewModel> 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),
|
||||
nameof(PasswordVisibilityAccessibilityText)
|
||||
});
|
||||
}
|
||||
public bool ShowCardNumber
|
||||
{
|
||||
get => _showCardNumber;
|
||||
set => SetProperty(ref _showCardNumber, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowCardNumberIcon)
|
||||
});
|
||||
}
|
||||
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 => GeneratedValueFormatter.Format(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}",
|
||||
_localizeService.GetLocaleShortDate(Cipher.RevisionDate.ToLocalTime()),
|
||||
_localizeService.GetLocaleShortTime(Cipher.RevisionDate.ToLocalTime()))
|
||||
});
|
||||
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}",
|
||||
_localizeService.GetLocaleShortDate(Cipher.PasswordRevisionDisplayDate?.ToLocalTime()),
|
||||
_localizeService.GetLocaleShortTime(Cipher.PasswordRevisionDisplayDate?.ToLocalTime()))
|
||||
});
|
||||
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 = ThemeManager.GetResourceColor("PrimaryColor")
|
||||
});
|
||||
return fs;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowUpgradePremiumTotpText => !CanAccessPremium && !Cipher.OrganizationUseTotp && ShowTotp;
|
||||
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);
|
||||
public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public string ShowCardNumberIcon => ShowCardNumber ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public string ShowCardCodeIcon => ShowCardCode ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
|
||||
public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow;
|
||||
public string TotpCodeFormatted
|
||||
{
|
||||
get => ShowUpgradePremiumTotpText ? string.Empty : _totpCodeFormatted;
|
||||
set => SetProperty(ref _totpCodeFormatted, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowTotp)
|
||||
});
|
||||
}
|
||||
public string TotpSec
|
||||
{
|
||||
get => _totpSec;
|
||||
set => SetProperty(ref _totpSec, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(TotpProgress)
|
||||
});
|
||||
}
|
||||
public bool TotpLow
|
||||
{
|
||||
get => _totpLow;
|
||||
set
|
||||
{
|
||||
SetProperty(ref _totpLow, value);
|
||||
Page.Resources["textTotp"] = ThemeManager.Resources()[value ? "text-danger" : "text-default"];
|
||||
}
|
||||
}
|
||||
public double TotpProgress => string.IsNullOrEmpty(TotpSec) ? 0 : double.Parse(TotpSec) * 100 / _totpInterval;
|
||||
public bool IsDeleted => Cipher.IsDeleted;
|
||||
public bool CanEdit => !Cipher.IsDeleted;
|
||||
public bool CanClone => Cipher.IsClonable;
|
||||
|
||||
public async Task<bool> LoadAsync(Action finishedLoadingAction = null)
|
||||
{
|
||||
var cipher = await _cipherService.GetAsync(CipherId);
|
||||
if (cipher == null)
|
||||
{
|
||||
finishedLoadingAction?.Invoke();
|
||||
return false;
|
||||
}
|
||||
Cipher = await cipher.DecryptAsync();
|
||||
CanAccessPremium = await _stateService.CanAccessPremiumAsync();
|
||||
|
||||
Fields = Cipher.Fields?
|
||||
.Select(f => _customFieldItemFactory.CreateCustomFieldItem(f, false, Cipher, this, CopyFieldCommand, null))
|
||||
.ToList();
|
||||
|
||||
if (Cipher.Type == Core.Enums.CipherType.Login && !string.IsNullOrWhiteSpace(Cipher.Login.Totp) &&
|
||||
(Cipher.OrganizationUseTotp || CanAccessPremium))
|
||||
{
|
||||
_totpTickHelper = new TotpHelper(Cipher);
|
||||
_totpTickCancellationToken?.Cancel();
|
||||
_totpInterval = _totpTickHelper.Interval;
|
||||
_totpTickCancellationToken = new CancellationTokenSource();
|
||||
_totpTickTask = new TimerTask(_logger, StartCiphersTotpTick, _totpTickCancellationToken).RunPeriodic();
|
||||
}
|
||||
if (_previousCipherId != CipherId)
|
||||
{
|
||||
var task = _eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientViewed, CipherId);
|
||||
}
|
||||
_previousCipherId = CipherId;
|
||||
finishedLoadingAction?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async void StartCiphersTotpTick()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _totpTickHelper.GenerateNewTotpValues();
|
||||
TotpSec = _totpTickHelper.TotpSec;
|
||||
TotpCodeFormatted = _totpTickHelper.TotpCodeFormatted;
|
||||
_totpInterval = _totpTickHelper.Interval;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Exception(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopCiphersTotpTick()
|
||||
{
|
||||
_totpTickCancellationToken?.Cancel();
|
||||
if (_totpTickTask != null)
|
||||
{
|
||||
await _totpTickTask;
|
||||
}
|
||||
}
|
||||
|
||||
public async void TogglePassword()
|
||||
{
|
||||
if (!await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShowPassword = !ShowPassword;
|
||||
if (ShowPassword)
|
||||
{
|
||||
var task = _eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientToggledPasswordVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public async void ToggleCardNumber()
|
||||
{
|
||||
if (!await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
ShowCardNumber = !ShowCardNumber;
|
||||
if (ShowCardNumber)
|
||||
{
|
||||
var task = _eventService.CollectAsync(
|
||||
Core.Enums.EventType.Cipher_ClientToggledCardNumberVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public async void ToggleCardCode()
|
||||
{
|
||||
if (!await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
ShowCardCode = !ShowCardCode;
|
||||
if (ShowCardCode)
|
||||
{
|
||||
var task = _eventService.CollectAsync(
|
||||
Core.Enums.EventType.Cipher_ClientToggledCardCodeVisible, CipherId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync()
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
var confirmed = await _platformUtilsService.ShowDialogAsync(
|
||||
Cipher.IsDeleted ? AppResources.DoYouReallyWantToPermanentlyDeleteCipher : AppResources.DoYouReallyWantToSoftDeleteCipher,
|
||||
null, AppResources.Yes, AppResources.Cancel);
|
||||
if (!confirmed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(Cipher.IsDeleted ? AppResources.Deleting : AppResources.SoftDeleting);
|
||||
if (Cipher.IsDeleted)
|
||||
{
|
||||
await _cipherService.DeleteWithServerAsync(Cipher.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _cipherService.SoftDeleteWithServerAsync(Cipher.Id);
|
||||
}
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
|
||||
_platformUtilsService.ShowToast("success", null,
|
||||
Cipher.IsDeleted ? AppResources.ItemDeleted : AppResources.ItemSoftDeleted);
|
||||
_messagingService.Send(Cipher.IsDeleted ? "deletedCipher" : "softDeletedCipher", Cipher);
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> RestoreAsync()
|
||||
{
|
||||
if (!IsDeleted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.DoYouReallyWantToRestoreCipher,
|
||||
null, AppResources.Yes, AppResources.Cancel);
|
||||
if (!confirmed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Restoring);
|
||||
await _cipherService.RestoreWithServerAsync(Cipher.Id);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.ItemRestored);
|
||||
_messagingService.Send("restoredCipher", Cipher);
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task TotpUpdateCodeAsync()
|
||||
{
|
||||
if (Cipher == null || Cipher.Type != Core.Enums.CipherType.Login || Cipher.Login.Totp == 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 Task DownloadAttachmentAsync(AttachmentView attachment)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
var canOpenFile = true;
|
||||
if (!_fileService.CanOpenFile(attachment.FileName))
|
||||
{
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
// iOS is currently hardcoded to always return CanOpenFile == true, but should it ever return false
|
||||
// for any reason we want to be sure to catch it here.
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.UnableToOpenFile);
|
||||
return;
|
||||
}
|
||||
|
||||
canOpenFile = false;
|
||||
}
|
||||
|
||||
if (!await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Downloading);
|
||||
var data = await _cipherService.DownloadAndDecryptAttachmentAsync(Cipher.Id, attachment, Cipher.OrganizationId);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (data == null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.UnableToDownloadFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
if (canOpenFile)
|
||||
{
|
||||
// We can open this attachment directly, so give the user the option to open or save
|
||||
PromptOpenOrSave(data, attachment);
|
||||
}
|
||||
else
|
||||
{
|
||||
// We can't open this attachment so go directly to save
|
||||
SaveAttachment(data, attachment);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenAttachment(data, attachment);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Exception(ex);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
|
||||
public async void PromptOpenOrSave(byte[] data, AttachmentView attachment)
|
||||
{
|
||||
var selection = await Page.DisplayActionSheet(attachment.FileName, AppResources.Cancel, null,
|
||||
AppResources.Open, AppResources.Save);
|
||||
if (selection == AppResources.Open)
|
||||
{
|
||||
OpenAttachment(data, attachment);
|
||||
}
|
||||
else if (selection == AppResources.Save)
|
||||
{
|
||||
SaveAttachment(data, attachment);
|
||||
}
|
||||
}
|
||||
|
||||
public async void OpenAttachment(byte[] data, AttachmentView attachment)
|
||||
{
|
||||
if (!_fileService.OpenFile(data, attachment.Id, attachment.FileName))
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.UnableToOpenFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async void SaveAttachment(byte[] data, AttachmentView attachment)
|
||||
{
|
||||
_attachmentData = data;
|
||||
_attachmentFilename = attachment.FileName;
|
||||
if (!_fileService.SaveFile(_attachmentData, null, _attachmentFilename, null))
|
||||
{
|
||||
ClearAttachmentData();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.UnableToSaveAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
public async void SaveFileSelected(string contentUri, string filename)
|
||||
{
|
||||
if (_fileService.SaveFile(_attachmentData, null, filename ?? _attachmentFilename, contentUri))
|
||||
{
|
||||
ClearAttachmentData();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.SaveAttachmentSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
ClearAttachmentData();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.UnableToSaveAttachment);
|
||||
}
|
||||
|
||||
private void ClearAttachmentData()
|
||||
{
|
||||
_attachmentData = null;
|
||||
_attachmentFilename = null;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(string id, string text = null)
|
||||
{
|
||||
if (_passwordRepromptService.ProtectedFields.Contains(id) && !await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 = TotpCodeFormatted.Replace(" ", string.Empty);
|
||||
name = AppResources.VerificationCodeTotp;
|
||||
}
|
||||
else if (id == "LoginUri")
|
||||
{
|
||||
name = AppResources.URI;
|
||||
}
|
||||
else if (id == "FieldValue" || id == "H_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 _clipboardService.CopyTextAsync(text);
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
_platformUtilsService.ShowToastForCopiedValue(name);
|
||||
}
|
||||
if (id == "LoginPassword")
|
||||
{
|
||||
await _eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientCopiedPassword, CipherId);
|
||||
}
|
||||
else if (id == "CardCode")
|
||||
{
|
||||
await _eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientCopiedCardCode, CipherId);
|
||||
}
|
||||
else if (id == "H_FieldValue")
|
||||
{
|
||||
await _eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientCopiedHiddenField, CipherId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LaunchUri(ILaunchableView launchableView)
|
||||
{
|
||||
if (launchableView.CanLaunch && (Page as BaseContentPage).DoOnce())
|
||||
{
|
||||
_platformUtilsService.LaunchUri(launchableView.LaunchUri);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloneAsync()
|
||||
{
|
||||
if (!await CanCloneAsync() || !await PromptPasswordAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var page = new CipherAddEditPage(CipherId, cloneMode: true, cipherDetailsPage: Page as CipherDetailsPage);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
|
||||
public async Task<bool> PromptPasswordAsync()
|
||||
{
|
||||
if (_passwordReprompted)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _passwordReprompted = await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(Cipher.Reprompt);
|
||||
}
|
||||
|
||||
private async Task<bool> CanCloneAsync()
|
||||
{
|
||||
if (!Cipher.HasFido2Key)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await _platformUtilsService.ShowDialogAsync(AppResources.ThePasskeyWillNotBeCopiedToTheClonedItemDoYouWantToContinueCloningThisItem, AppResources.PasskeyWillNotBeCopied, AppResources.Yes, AppResources.No);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/Core/Pages/Vault/CipherSelectionPage.xaml
Normal file
160
src/Core/Pages/Vault/CipherSelectionPage.xaml
Normal file
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:xct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
|
||||
x:Class="Bit.App.Pages.CipherSelectionPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:effects="clr-namespace:Bit.App.Effects"
|
||||
x:DataType="pages:CipherSelectionPageViewModel"
|
||||
Title="{Binding PageTitle}"
|
||||
x:Name="_page">
|
||||
<ContentPage.ToolbarItems>
|
||||
<controls:ExtendedToolbarItem
|
||||
x:Name="_accountAvatar"
|
||||
IconImageSource="{Binding AvatarImageSource}"
|
||||
Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}"
|
||||
Order="Primary"
|
||||
Priority="-1"
|
||||
UseOriginalImage="True"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Account}"
|
||||
AutomationId="AccountIconButton" />
|
||||
<ToolbarItem IconImageSource="search.png" Clicked="Search_Clicked"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Search}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<controls:SelectionChangedEventArgsConverter x:Key="SelectionChangedEventArgsConverter" />
|
||||
|
||||
<ToolbarItem
|
||||
x:Name="_closeItem"
|
||||
x:Key="_closeItem"
|
||||
Text="{u:I18n Close}"
|
||||
Clicked="CloseItem_Clicked"
|
||||
Order="Primary"
|
||||
Priority="-1" />
|
||||
<ToolbarItem x:Name="_addItem" x:Key="addItem"
|
||||
IconImageSource="plus.png"
|
||||
Command="{Binding AddCipherCommand}"
|
||||
Order="Primary"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n AddItem}" />
|
||||
|
||||
<DataTemplate x:Key="cipherTemplate"
|
||||
x:DataType="pages:GroupingsPageListItem">
|
||||
<controls:CipherViewCell
|
||||
Cipher="{Binding Cipher}"
|
||||
ButtonCommand="{Binding BindingContext.CipherOptionsCommand, Source={x:Reference _page}}"
|
||||
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate
|
||||
x:Key="headerTemplate"
|
||||
x:DataType="pages:GroupingsPageHeaderListItem">
|
||||
<StackLayout
|
||||
Spacing="0" Padding="0" VerticalOptions="FillAndExpand"
|
||||
StyleClass="list-row-header-container, list-row-header-container-platform">
|
||||
<BoxView
|
||||
StyleClass="list-section-separator-top, list-section-separator-top-platform" />
|
||||
<StackLayout StyleClass="list-row-header, list-row-header-platform">
|
||||
<Label
|
||||
Text="{Binding Title}"
|
||||
StyleClass="list-header, list-header-platform" />
|
||||
<Label
|
||||
Text="{Binding ItemCount}"
|
||||
StyleClass="list-header-sub" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
|
||||
<pages:GroupingsPageListItemSelector x:Key="listItemDataTemplateSelector"
|
||||
HeaderTemplate="{StaticResource headerTemplate}"
|
||||
CipherTemplate="{StaticResource cipherTemplate}" />
|
||||
|
||||
<StackLayout x:Key="mainLayout" x:Name="_mainLayout">
|
||||
<StackLayout
|
||||
VerticalOptions="CenterAndExpand"
|
||||
Padding="20, 0"
|
||||
Spacing="20"
|
||||
IsVisible="{Binding ShowNoData}">
|
||||
<Image
|
||||
Source="empty_items_state" />
|
||||
<Label
|
||||
Text="{Binding NoDataText}"
|
||||
HorizontalTextAlignment="Center"></Label>
|
||||
<Button
|
||||
Text="{u:I18n AddAnItem}"
|
||||
Command="{Binding AddCipherCommand}" />
|
||||
</StackLayout>
|
||||
|
||||
<Frame
|
||||
IsVisible="{Binding ShowCallout}"
|
||||
Padding="10"
|
||||
Margin="20, 10"
|
||||
HasShadow="False"
|
||||
BackgroundColor="Transparent"
|
||||
BorderColor="{DynamicResource PrimaryColor}">
|
||||
<Label
|
||||
Text="{u:I18n AddTheKeyToAnExistingOrNewItem}"
|
||||
StyleClass="text-muted, text-sm, text-bold"
|
||||
HorizontalTextAlignment="Center" />
|
||||
</Frame>
|
||||
|
||||
<controls:ExtendedCollectionView
|
||||
IsVisible="{Binding ShowList}"
|
||||
ItemsSource="{Binding GroupedItems}"
|
||||
VerticalOptions="FillAndExpand"
|
||||
ItemTemplate="{StaticResource listItemDataTemplateSelector}"
|
||||
SelectionMode="Single"
|
||||
StyleClass="list, list-platform"
|
||||
ExtraDataForLogging="Autofill Ciphers Page">
|
||||
<controls:ExtendedCollectionView.Behaviors>
|
||||
<xct:EventToCommandBehavior
|
||||
EventName="SelectionChanged"
|
||||
Command="{Binding SelectCipherCommand}"
|
||||
EventArgsConverter="{StaticResource SelectionChangedEventArgsConverter}" />
|
||||
</controls:ExtendedCollectionView.Behaviors>
|
||||
</controls:ExtendedCollectionView>
|
||||
</StackLayout>
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<AbsoluteLayout
|
||||
x:Name="_absLayout"
|
||||
VerticalOptions="FillAndExpand"
|
||||
HorizontalOptions="FillAndExpand">
|
||||
<ContentView
|
||||
x:Name="_mainContent"
|
||||
AbsoluteLayout.LayoutFlags="All"
|
||||
AbsoluteLayout.LayoutBounds="0, 0, 1, 1">
|
||||
</ContentView>
|
||||
|
||||
<!-- Android FAB -->
|
||||
<Button
|
||||
x:Name="_fab"
|
||||
ImageSource="plus.png"
|
||||
Command="{Binding AddCipherCommand}"
|
||||
Style="{StaticResource btn-fab}"
|
||||
IsVisible="{OnPlatform iOS=false, Android=true}"
|
||||
AbsoluteLayout.LayoutFlags="PositionProportional"
|
||||
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize">
|
||||
<Button.Effects>
|
||||
<effects:FabShadowEffect />
|
||||
</Button.Effects>
|
||||
</Button>
|
||||
|
||||
<controls:AccountSwitchingOverlayView
|
||||
x:Name="_accountListOverlay"
|
||||
AbsoluteLayout.LayoutBounds="0, 0, 1, 1"
|
||||
AbsoluteLayout.LayoutFlags="All"
|
||||
MainPage="{Binding Source={x:Reference _page}}"
|
||||
MainFab="{Binding Source={x:Reference _fab}, Path=.}"
|
||||
BindingContext="{Binding AccountSwitchingOverlayViewModel}"/>
|
||||
</AbsoluteLayout>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
188
src/Core/Pages/Vault/CipherSelectionPage.xaml.cs
Normal file
188
src/Core/Pages/Vault/CipherSelectionPage.xaml.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Models;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class CipherSelectionPage : BaseContentPage
|
||||
{
|
||||
private readonly AppOptions _appOptions;
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
private readonly ISyncService _syncService;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
private readonly IAccountsManager _accountsManager;
|
||||
|
||||
private readonly CipherSelectionPageViewModel _vm;
|
||||
|
||||
public CipherSelectionPage(AppOptions appOptions)
|
||||
{
|
||||
_appOptions = appOptions;
|
||||
|
||||
if (appOptions?.OtpData is null)
|
||||
{
|
||||
BindingContext = new AutofillCiphersPageViewModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
BindingContext = new OTPCipherSelectionPageViewModel();
|
||||
}
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
ToolbarItems.Add(_closeItem);
|
||||
ToolbarItems.Add(_addItem);
|
||||
}
|
||||
|
||||
SetActivityIndicator(_mainContent);
|
||||
_vm = BindingContext as CipherSelectionPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.Init(appOptions);
|
||||
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
|
||||
}
|
||||
|
||||
protected async override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_syncService.SyncInProgress)
|
||||
{
|
||||
IsBusy = true;
|
||||
}
|
||||
if (!await AppHelpers.IsVaultTimeoutImmediateAsync())
|
||||
{
|
||||
await _vaultTimeoutService.CheckVaultTimeoutAsync();
|
||||
}
|
||||
if (await _vaultTimeoutService.IsLockedAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// don't crash the app if the avatar can't be loaded, just log the ex
|
||||
_accountAvatar?.OnAppearing();
|
||||
_vm.AvatarImageSource = await GetAvatarImageSourceAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
|
||||
_broadcasterService.Subscribe(nameof(CipherSelectionPage), async (message) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message.Command == "syncStarted")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() => IsBusy = true);
|
||||
}
|
||||
else if (message.Command == "syncCompleted")
|
||||
{
|
||||
await Task.Delay(500);
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsBusy = false;
|
||||
if (_vm.LoadedOnce)
|
||||
{
|
||||
var task = _vm.LoadAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
});
|
||||
|
||||
await LoadOnAppearedAsync(_mainLayout, false, async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _vm.LoadAsync();
|
||||
}
|
||||
catch (Exception e) when (e.Message.Contains("No key."))
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
await _vm.LoadAsync();
|
||||
}
|
||||
}, _mainContent);
|
||||
}
|
||||
|
||||
protected override bool OnBackButtonPressed()
|
||||
{
|
||||
if (_accountListOverlay.IsVisible)
|
||||
{
|
||||
_accountListOverlay.HideAsync().FireAndForget();
|
||||
return true;
|
||||
}
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
_appOptions.Uri = null;
|
||||
}
|
||||
return base.OnBackButtonPressed();
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
IsBusy = false;
|
||||
_accountAvatar?.OnDisappearing();
|
||||
}
|
||||
|
||||
private void AddButton_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vm is AutofillCiphersPageViewModel autofillVM)
|
||||
{
|
||||
AddFromAutofill(autofillVM).FireAndForget();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddFromAutofill(AutofillCiphersPageViewModel autofillVM)
|
||||
{
|
||||
if (_appOptions.FillType.HasValue && _appOptions.FillType != CipherType.Login)
|
||||
{
|
||||
var pageForOther = new CipherAddEditPage(type: _appOptions.FillType, fromAutofill: true);
|
||||
await Navigation.PushModalAsync(new NavigationPage(pageForOther));
|
||||
return;
|
||||
}
|
||||
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: autofillVM.Uri, name: _vm.Name,
|
||||
fromAutofill: true);
|
||||
await Navigation.PushModalAsync(new NavigationPage(pageForLogin));
|
||||
}
|
||||
|
||||
private void Search_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
var page = new CiphersPage(null, appOptions: _appOptions);
|
||||
Navigation.PushModalAsync(new NavigationPage(page)).FireAndForget();
|
||||
}
|
||||
|
||||
void CloseItem_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
_accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null).FireAndForget();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
src/Core/Pages/Vault/CipherSelectionPageViewModel.cs
Normal file
169
src/Core/Pages/Vault/CipherSelectionPageViewModel.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Controls;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public abstract class CipherSelectionPageViewModel : BaseViewModel
|
||||
{
|
||||
protected readonly IPlatformUtilsService _platformUtilsService;
|
||||
protected readonly IDeviceActionService _deviceActionService;
|
||||
protected readonly IAutofillHandler _autofillHandler;
|
||||
protected readonly ICipherService _cipherService;
|
||||
protected readonly IStateService _stateService;
|
||||
protected readonly IPasswordRepromptService _passwordRepromptService;
|
||||
protected readonly IMessagingService _messagingService;
|
||||
protected readonly ILogger _logger;
|
||||
|
||||
protected bool _showNoData;
|
||||
protected bool _showList;
|
||||
protected string _noDataText;
|
||||
protected bool _websiteIconsEnabled;
|
||||
|
||||
public CipherSelectionPageViewModel()
|
||||
{
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>();
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>();
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>();
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_stateService = ServiceContainer.Resolve<IStateService>();
|
||||
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>();
|
||||
_messagingService = ServiceContainer.Resolve<IMessagingService>();
|
||||
_logger = ServiceContainer.Resolve<ILogger>();
|
||||
|
||||
GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>();
|
||||
CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService),
|
||||
onException: ex => HandleException(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
SelectCipherCommand = new AsyncCommand<IGroupingsPageListItem>(SelectCipherAsync,
|
||||
onException: ex => HandleException(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
AddCipherCommand = new AsyncCommand(AddCipherAsync,
|
||||
onException: ex => HandleException(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
|
||||
AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger)
|
||||
{
|
||||
AllowAddAccountRow = false
|
||||
};
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public bool LoadedOnce { get; set; }
|
||||
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
|
||||
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
|
||||
|
||||
public ICommand CipherOptionsCommand { get; set; }
|
||||
public ICommand SelectCipherCommand { get; set; }
|
||||
public ICommand AddCipherCommand { get; set; }
|
||||
|
||||
public bool ShowNoData
|
||||
{
|
||||
get => _showNoData;
|
||||
set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[] { nameof(ShowCallout) });
|
||||
}
|
||||
|
||||
public bool ShowList
|
||||
{
|
||||
get => _showList;
|
||||
set => SetProperty(ref _showList, value);
|
||||
}
|
||||
|
||||
public string NoDataText
|
||||
{
|
||||
get => _noDataText;
|
||||
set => SetProperty(ref _noDataText, value);
|
||||
}
|
||||
|
||||
public bool WebsiteIconsEnabled
|
||||
{
|
||||
get => _websiteIconsEnabled;
|
||||
set => SetProperty(ref _websiteIconsEnabled, value);
|
||||
}
|
||||
|
||||
public virtual bool ShowCallout => false;
|
||||
|
||||
public abstract void Init(Models.AppOptions options);
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
LoadedOnce = true;
|
||||
ShowList = false;
|
||||
ShowNoData = false;
|
||||
WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault();
|
||||
|
||||
var groupedItems = await LoadGroupedItemsAsync();
|
||||
|
||||
// TODO: refactor this
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android
|
||||
||
|
||||
GroupedItems.Any())
|
||||
{
|
||||
var items = new List<IGroupingsPageListItem>();
|
||||
foreach (var itemGroup in groupedItems)
|
||||
{
|
||||
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
|
||||
items.AddRange(itemGroup);
|
||||
}
|
||||
|
||||
GroupedItems.ReplaceRange(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
// HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list
|
||||
var first = true;
|
||||
var items = new List<IGroupingsPageListItem>();
|
||||
foreach (var itemGroup in groupedItems)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
|
||||
}
|
||||
else
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
items.AddRange(itemGroup);
|
||||
}
|
||||
|
||||
await Device.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
if (groupedItems.Any())
|
||||
{
|
||||
GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) });
|
||||
GroupedItems.AddRange(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
GroupedItems.Clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
await Device.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
ShowList = groupedItems.Any();
|
||||
ShowNoData = !ShowList;
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync();
|
||||
|
||||
protected abstract Task SelectCipherAsync(IGroupingsPageListItem item);
|
||||
|
||||
protected abstract Task AddCipherAsync();
|
||||
}
|
||||
}
|
||||
123
src/Core/Pages/Vault/CiphersPage.xaml
Normal file
123
src/Core/Pages/Vault/CiphersPage.xaml
Normal file
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.CiphersPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:views="clr-namespace:Bit.Core.Models.View"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:CiphersPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:CiphersPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1"
|
||||
x:Name="_closeItem" x:Key="closeItem" />
|
||||
<StackLayout
|
||||
Orientation="Horizontal"
|
||||
VerticalOptions="FillAndExpand"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
Spacing="0"
|
||||
Padding="0"
|
||||
x:Name="_titleLayout"
|
||||
x:Key="titleLayout">
|
||||
<controls:MiButton
|
||||
StyleClass="btn-title, btn-title-platform"
|
||||
Text=""
|
||||
VerticalOptions="CenterAndExpand"
|
||||
Clicked="BackButton_Clicked"
|
||||
x:Name="_backButton"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n TapToGoBack}"/>
|
||||
<controls:ExtendedSearchBar
|
||||
x:Name="_searchBar"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
TextChanged="SearchBar_TextChanged"
|
||||
SearchButtonPressed="SearchBar_SearchButtonPressed"
|
||||
Placeholder="{Binding PageTitle}"
|
||||
AutomationId="SearchBar" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="list-section-separator-bottom, list-section-separator-bottom-platform"
|
||||
x:Name="_separator" x:Key="separator" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<StackLayout x:Name="_mainLayout" Spacing="0" Padding="0">
|
||||
<StackLayout
|
||||
IsVisible="{Binding ShowVaultFilter}"
|
||||
Orientation="Horizontal"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
Margin="0,5,0,0">
|
||||
<Label
|
||||
Text="{Binding VaultFilterDescription}"
|
||||
LineBreakMode="TailTruncation"
|
||||
Margin="10,0"
|
||||
StyleClass="text-md, text-muted"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="StartAndExpand"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Filter}" />
|
||||
<controls:MiButton
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.ViewCellMenu}}"
|
||||
StyleClass="list-row-button-text, list-row-button-platform"
|
||||
Command="{Binding VaultFilterCommand}"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="End"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Filter}" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
|
||||
<controls:IconLabel IsVisible="{Binding ShowSearchDirection}"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Search}}"
|
||||
StyleClass="text-muted"
|
||||
FontSize="50"
|
||||
VerticalOptions="CenterAndExpand"
|
||||
HorizontalOptions="CenterAndExpand"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<StackLayout
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="StartAndExpand"
|
||||
Margin="20, 80, 20, 0"
|
||||
Spacing="20"
|
||||
IsVisible="{Binding ShowNoData}">
|
||||
<Image
|
||||
Source="empty_items_state" />
|
||||
<Label
|
||||
Text="{u:I18n ThereAreNoItemsThatMatchTheSearch}"
|
||||
HorizontalTextAlignment="Center"
|
||||
AutomationId="NoSearchResultsLabel" />
|
||||
<Button
|
||||
Text="{u:I18n AddAnItem}"
|
||||
Command="{Binding AddCipherCommand}"
|
||||
IsVisible="{Binding ShowAddCipher}"/>
|
||||
</StackLayout>
|
||||
<controls:ExtendedCollectionView
|
||||
IsVisible="{Binding ShowList}"
|
||||
ItemsSource="{Binding Ciphers}"
|
||||
VerticalOptions="FillAndExpand"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="RowSelected"
|
||||
StyleClass="list, list-platform"
|
||||
ExtraDataForLogging="Ciphers Page"
|
||||
AutomationId="CipherList">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:CipherView">
|
||||
<controls:CipherViewCell
|
||||
Cipher="{Binding .}"
|
||||
ButtonCommand="{Binding BindingContext.CipherOptionsCommand, Source={x:Reference _page}}"
|
||||
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}"
|
||||
/>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</controls:ExtendedCollectionView>
|
||||
</StackLayout>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
139
src/Core/Pages/Vault/CiphersPage.xaml.cs
Normal file
139
src/Core/Pages/Vault/CiphersPage.xaml.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Bit.App.Controls;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class CiphersPage : BaseContentPage
|
||||
{
|
||||
private readonly string _autofillUrl;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
|
||||
private CiphersPageViewModel _vm;
|
||||
private bool _hasFocused;
|
||||
|
||||
public CiphersPage(Func<CipherView, bool> filter,
|
||||
string pageTitle = null,
|
||||
string vaultFilterSelection = null,
|
||||
bool deleted = false,
|
||||
AppOptions appOptions = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_vm = BindingContext as CiphersPageViewModel;
|
||||
_vm.Page = this;
|
||||
_autofillUrl = appOptions?.Uri;
|
||||
_vm.Prepare(filter, deleted, appOptions);
|
||||
|
||||
if (pageTitle != null)
|
||||
{
|
||||
_vm.PageTitle = string.Format(AppResources.SearchGroup, pageTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.PageTitle = AppResources.SearchVault;
|
||||
}
|
||||
_vm.VaultFilterDescription = vaultFilterSelection;
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
ToolbarItems.Add(_closeItem);
|
||||
_searchBar.Placeholder = AppResources.Search;
|
||||
_mainLayout.Children.Insert(0, _searchBar);
|
||||
_mainLayout.Children.Insert(1, _separator);
|
||||
ShowModalAnimationDelay = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationPage.SetTitleView(this, _titleLayout);
|
||||
}
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
}
|
||||
|
||||
public SearchBar SearchBar => _searchBar;
|
||||
|
||||
protected async override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await _vm.InitAsync();
|
||||
if (!_hasFocused)
|
||||
{
|
||||
_hasFocused = true;
|
||||
RequestFocus(_searchBar);
|
||||
}
|
||||
}
|
||||
|
||||
private void SearchBar_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
var oldLength = e.OldTextValue?.Length ?? 0;
|
||||
var newLength = e.NewTextValue?.Length ?? 0;
|
||||
if (oldLength < 2 && newLength < 2 && oldLength < newLength)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_vm.Search(e.NewTextValue, 200);
|
||||
}
|
||||
|
||||
private void SearchBar_SearchButtonPressed(object sender, EventArgs e)
|
||||
{
|
||||
_vm.Search((sender as SearchBar).Text);
|
||||
}
|
||||
|
||||
private void BackButton_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
GoBack();
|
||||
}
|
||||
|
||||
protected override bool OnBackButtonPressed()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_autofillUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
GoBack();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(_autofillUrl))
|
||||
{
|
||||
Navigation.PopModalAsync(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_autofillHandler.CloseAutofill();
|
||||
}
|
||||
}
|
||||
|
||||
private async void RowSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
((ExtendedCollectionView)sender).SelectedItem = null;
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.CurrentSelection?.FirstOrDefault() is CipherView cipher)
|
||||
{
|
||||
await _vm.SelectCipherAsync(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
private void Close_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
GoBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
277
src/Core/Pages/Vault/CiphersPageViewModel.cs
Normal file
277
src/Core/Pages/Vault/CiphersPageViewModel.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Models;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
using Bit.App.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class CiphersPageViewModel : VaultFilterViewModel
|
||||
{
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly ISearchService _searchService;
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IPasswordRepromptService _passwordRepromptService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private CancellationTokenSource _searchCancellationTokenSource;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private bool _showNoData;
|
||||
private bool _showList;
|
||||
private bool _websiteIconsEnabled;
|
||||
private AppOptions _appOptions;
|
||||
|
||||
public CiphersPageViewModel()
|
||||
{
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_searchService = ServiceContainer.Resolve<ISearchService>("searchService");
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
|
||||
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
|
||||
_policyService = ServiceContainer.Resolve<IPolicyService>("policyService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>("logger");
|
||||
|
||||
Ciphers = new ExtendedObservableCollection<CipherView>();
|
||||
CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService),
|
||||
onException: ex => HandleException(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
AddCipherCommand = new AsyncCommand(AddCipherAsync,
|
||||
onException: ex => HandleException(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
}
|
||||
|
||||
public ICommand CipherOptionsCommand { get; }
|
||||
public ICommand AddCipherCommand { get; }
|
||||
public ExtendedObservableCollection<CipherView> Ciphers { get; set; }
|
||||
public Func<CipherView, bool> Filter { get; set; }
|
||||
public string AutofillUrl { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public bool ShowAllIfSearchTextEmpty { get; set; }
|
||||
|
||||
protected override ICipherService cipherService => _cipherService;
|
||||
protected override IPolicyService policyService => _policyService;
|
||||
protected override IOrganizationService organizationService => _organizationService;
|
||||
protected override ILogger logger => _logger;
|
||||
|
||||
public bool ShowNoData
|
||||
{
|
||||
get => _showNoData;
|
||||
set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowSearchDirection),
|
||||
nameof(ShowAddCipher)
|
||||
});
|
||||
}
|
||||
|
||||
public bool ShowList
|
||||
{
|
||||
get => _showList;
|
||||
set => SetProperty(ref _showList, value, additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ShowSearchDirection)
|
||||
});
|
||||
}
|
||||
|
||||
public bool ShowSearchDirection => !ShowList && !ShowNoData;
|
||||
|
||||
public bool ShowAddCipher => ShowNoData && _appOptions?.OtpData != null;
|
||||
|
||||
public bool WebsiteIconsEnabled
|
||||
{
|
||||
get => _websiteIconsEnabled;
|
||||
set => SetProperty(ref _websiteIconsEnabled, value);
|
||||
}
|
||||
|
||||
internal void Prepare(Func<CipherView, bool> filter, bool deleted, AppOptions appOptions)
|
||||
{
|
||||
Filter = filter;
|
||||
AutofillUrl = appOptions?.Uri;
|
||||
Deleted = deleted;
|
||||
ShowAllIfSearchTextEmpty = appOptions?.OtpData != null;
|
||||
_appOptions = appOptions;
|
||||
}
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
await InitVaultFilterAsync(true);
|
||||
WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault();
|
||||
PerformSearchIfPopulated();
|
||||
}
|
||||
|
||||
public void Search(string searchText, int? timeout = null)
|
||||
{
|
||||
var previousCts = _searchCancellationTokenSource;
|
||||
var cts = new CancellationTokenSource();
|
||||
Task.Run(async () =>
|
||||
{
|
||||
List<CipherView> ciphers = null;
|
||||
var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1;
|
||||
var shouldShowAllWhenEmpty = ShowAllIfSearchTextEmpty && string.IsNullOrEmpty(searchText);
|
||||
if (searchable || shouldShowAllWhenEmpty)
|
||||
{
|
||||
if (timeout != null)
|
||||
{
|
||||
await Task.Delay(timeout.Value);
|
||||
}
|
||||
if (searchText != (Page as CiphersPage).SearchBar.Text
|
||||
&&
|
||||
!shouldShowAllWhenEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
previousCts?.Cancel();
|
||||
try
|
||||
{
|
||||
var vaultFilteredCiphers = await GetAllCiphersAsync();
|
||||
if (!shouldShowAllWhenEmpty)
|
||||
{
|
||||
ciphers = await _searchService.SearchCiphersAsync(searchText,
|
||||
Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
ciphers = vaultFilteredCiphers;
|
||||
}
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ciphers == null)
|
||||
{
|
||||
ciphers = new List<CipherView>();
|
||||
}
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
Ciphers.ResetWithRange(ciphers);
|
||||
ShowNoData = !shouldShowAllWhenEmpty && searchable && Ciphers.Count == 0;
|
||||
ShowList = (searchable || shouldShowAllWhenEmpty) && !ShowNoData;
|
||||
});
|
||||
}, cts.Token);
|
||||
_searchCancellationTokenSource = cts;
|
||||
}
|
||||
|
||||
public async Task SelectCipherAsync(CipherView cipher)
|
||||
{
|
||||
string selection = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AutofillUrl))
|
||||
{
|
||||
var options = new List<string> { AppResources.Autofill };
|
||||
if (cipher.Type == CipherType.Login &&
|
||||
Microsoft.Maui.Networking.Connectivity.NetworkAccess != Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
options.Add(AppResources.AutofillAndSave);
|
||||
}
|
||||
options.Add(AppResources.View);
|
||||
selection = await Page.DisplayActionSheet(AppResources.AutofillOrView, AppResources.Cancel, null,
|
||||
options.ToArray());
|
||||
}
|
||||
|
||||
if (_appOptions?.OtpData != null)
|
||||
{
|
||||
if (!await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(cipher.Reprompt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection == AppResources.View || string.IsNullOrWhiteSpace(AutofillUrl))
|
||||
{
|
||||
var page = new CipherDetailsPage(cipher.Id);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
else if (selection == AppResources.Autofill || selection == AppResources.AutofillAndSave)
|
||||
{
|
||||
if (!await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(cipher.Reprompt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection == AppResources.AutofillAndSave)
|
||||
{
|
||||
var uris = cipher.Login?.Uris?.ToList();
|
||||
if (uris == null)
|
||||
{
|
||||
uris = new List<LoginUriView>();
|
||||
}
|
||||
uris.Add(new LoginUriView
|
||||
{
|
||||
Uri = AutofillUrl,
|
||||
Match = null
|
||||
});
|
||||
cipher.Login.Uris = uris;
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher));
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_deviceActionService.SystemMajorVersion() < 21)
|
||||
{
|
||||
await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
|
||||
}
|
||||
else
|
||||
{
|
||||
_autofillHandler.Autofill(cipher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformSearchIfPopulated()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace((Page as CiphersPage).SearchBar.Text) || ShowAllIfSearchTextEmpty)
|
||||
{
|
||||
Search((Page as CiphersPage).SearchBar.Text, 200);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnVaultFilterSelectedAsync()
|
||||
{
|
||||
PerformSearchIfPopulated();
|
||||
}
|
||||
|
||||
private async Task AddCipherAsync()
|
||||
{
|
||||
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: _appOptions?.OtpData?.Issuer ?? _appOptions?.OtpData?.AccountName, appOptions: _appOptions);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Core/Pages/Vault/CollectionsPage.xaml
Normal file
58
src/Core/Pages/Vault/CollectionsPage.xaml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.CollectionsPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
x:DataType="pages:CollectionsPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
<ContentPage.BindingContext>
|
||||
<pages:CollectionsPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Cancel}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
|
||||
<ToolbarItem Text="{u:I18n Save}" Clicked="Save_Clicked" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:IsNotNullConverter x:Key="notNull" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ScrollView x:Name="_scrollView">
|
||||
<StackLayout Spacing="20">
|
||||
<StackLayout StyleClass="box">
|
||||
<StackLayout StyleClass="box-row" Padding="10, 20"
|
||||
IsVisible="{Binding HasCollections, Converter={StaticResource inverseBool}}">
|
||||
<Label Text="{u:I18n NoCollectionsToList}" HorizontalTextAlignment="Center" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Collections}" IsVisible="{Binding HasCollections}">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="pages:CollectionViewModel">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row, box-row-switch">
|
||||
<Label
|
||||
Text="{Binding Collection.Name}"
|
||||
StyleClass="box-label-regular"
|
||||
HorizontalOptions="StartAndExpand" />
|
||||
<Switch
|
||||
IsToggled="{Binding Checked}"
|
||||
StyleClass="box-value"
|
||||
HorizontalOptions="End" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
51
src/Core/Pages/Vault/CollectionsPage.xaml.cs
Normal file
51
src/Core/Pages/Vault/CollectionsPage.xaml.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class CollectionsPage : BaseContentPage
|
||||
{
|
||||
private CollectionsPageViewModel _vm;
|
||||
|
||||
public CollectionsPage(string cipherId)
|
||||
{
|
||||
InitializeComponent();
|
||||
_vm = BindingContext as CollectionsPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
SetActivityIndicator();
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await LoadOnAppearedAsync(_scrollView, true, () => _vm.LoadAsync());
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
private async void Save_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await _vm.SubmitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/Core/Pages/Vault/CollectionsPageViewModel.cs
Normal file
97
src/Core/Pages/Vault/CollectionsPageViewModel.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class CollectionsPageViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private CipherView _cipher;
|
||||
private Cipher _cipherDomain;
|
||||
private bool _hasCollections;
|
||||
|
||||
public CollectionsPageViewModel()
|
||||
{
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_collectionService = ServiceContainer.Resolve<ICollectionService>("collectionService");
|
||||
Collections = new ExtendedObservableCollection<CollectionViewModel>();
|
||||
PageTitle = AppResources.Collections;
|
||||
}
|
||||
|
||||
public string CipherId { get; set; }
|
||||
public ExtendedObservableCollection<CollectionViewModel> Collections { get; set; }
|
||||
public bool HasCollections
|
||||
{
|
||||
get => _hasCollections;
|
||||
set => SetProperty(ref _hasCollections, value);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
_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
|
||||
{
|
||||
Collection = c,
|
||||
Checked = collectionIds.Contains(c.Id)
|
||||
}).ToList();
|
||||
Collections.ResetWithRange(collections);
|
||||
HasCollections = Collections.Any();
|
||||
}
|
||||
|
||||
public async Task<bool> SubmitAsync()
|
||||
{
|
||||
var selectedCollectionIds = Collections?.Where(c => c.Checked).Select(c => c.Collection.Id);
|
||||
if (!selectedCollectionIds?.Any() ?? true)
|
||||
{
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.SelectOneCollection,
|
||||
AppResources.Ok);
|
||||
return false;
|
||||
}
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cipherDomain.CollectionIds = new HashSet<string>(selectedCollectionIds);
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
await _cipherService.SaveCollectionsWithServerAsync(_cipherDomain);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.ItemUpdated);
|
||||
await Page.Navigation.PopModalAsync();
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
215
src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml
Normal file
215
src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml
Normal file
@@ -0,0 +1,215 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.GroupingsPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:effects="clr-namespace:Bit.App.Effects"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:xct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:GroupingsPageViewModel"
|
||||
Title="{Binding PageTitle}"
|
||||
x:Name="_page">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:GroupingsPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<controls:ExtendedToolbarItem
|
||||
x:Name="_accountAvatar"
|
||||
IconImageSource="{Binding AvatarImageSource}"
|
||||
Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}"
|
||||
Order="Primary"
|
||||
Priority="-1"
|
||||
UseOriginalImage="True"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Account}"
|
||||
AutomationId="AccountIconButton" />
|
||||
<ToolbarItem IconImageSource="search.png" Clicked="Search_Clicked"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Search}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
|
||||
<ToolbarItem x:Name="_syncItem" x:Key="syncItem" Text="{u:I18n Sync}"
|
||||
Clicked="Sync_Clicked" Order="Secondary" />
|
||||
<ToolbarItem x:Name="_lockItem" x:Key="lockItem" Text="{u:I18n Lock}"
|
||||
Clicked="Lock_Clicked" Order="Secondary" />
|
||||
<ToolbarItem x:Name="_exitItem" x:Key="exitItem" Text="{u:I18n Exit}"
|
||||
Clicked="Exit_Clicked" Order="Secondary" />
|
||||
<ToolbarItem x:Name="_addItem" x:Key="addItem" IconImageSource="plus.png"
|
||||
Clicked="AddButton_Clicked" Order="Primary"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n AddItem}" />
|
||||
|
||||
<DataTemplate x:Key="cipherTemplate"
|
||||
x:DataType="pages:GroupingsPageListItem">
|
||||
<controls:CipherViewCell
|
||||
Cipher="{Binding Cipher}"
|
||||
ButtonCommand="{Binding BindingContext.CipherOptionsCommand, Source={x:Reference _page}}"
|
||||
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="authenticatorTemplate"
|
||||
x:DataType="pages:GroupingsPageTOTPListItem">
|
||||
<controls:AuthenticatorViewCell
|
||||
Cipher="{Binding Cipher}"
|
||||
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}"
|
||||
TotpSec="{Binding TotpSec}" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="groupTemplate"
|
||||
x:DataType="pages:GroupingsPageListItem">
|
||||
<controls:ExtendedStackLayout Orientation="Horizontal"
|
||||
StyleClass="list-row, list-row-platform"
|
||||
AutomationId="{Binding AutomationId}">
|
||||
<controls:IconLabel Text="{Binding Icon, Mode=OneWay}"
|
||||
HorizontalOptions="Start"
|
||||
VerticalOptions="Center"
|
||||
StyleClass="list-icon, list-icon-platform"
|
||||
ShouldUpdateFontSizeDynamicallyForAccesibility="True">
|
||||
<controls:IconLabel.Effects>
|
||||
<effects:FixedSizeEffect />
|
||||
</controls:IconLabel.Effects>
|
||||
</controls:IconLabel>
|
||||
<Label Text="{Binding Name, Mode=OneWay}"
|
||||
LineBreakMode="TailTruncation"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
VerticalOptions="CenterAndExpand"
|
||||
StyleClass="list-title"
|
||||
AutomationId="ItemNameLabel" />
|
||||
<Label Text="{Binding ItemCount, Mode=OneWay}"
|
||||
HorizontalOptions="End"
|
||||
VerticalOptions="CenterAndExpand"
|
||||
HorizontalTextAlignment="End"
|
||||
StyleClass="list-sub"
|
||||
AutomationId="ItemCountLabel" />
|
||||
</controls:ExtendedStackLayout>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate
|
||||
x:Key="headerTemplate"
|
||||
x:DataType="pages:GroupingsPageHeaderListItem">
|
||||
<StackLayout
|
||||
Spacing="0"
|
||||
Padding="0"
|
||||
VerticalOptions="FillAndExpand"
|
||||
StyleClass="list-row-header-container, list-row-header-container-platform"
|
||||
AutomationId="{Binding AutomationId}">
|
||||
<BoxView
|
||||
StyleClass="list-section-separator-top, list-section-separator-top-platform" />
|
||||
<StackLayout StyleClass="list-row-header, list-row-header-platform">
|
||||
<Label
|
||||
Text="{Binding Title}"
|
||||
StyleClass="list-header, list-header-platform" />
|
||||
<Label
|
||||
Text="{Binding ItemCount}"
|
||||
StyleClass="list-header-sub"
|
||||
AutomationId="SectionItemCount" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="list-section-separator-bottom, list-section-separator-bottom-platform" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
|
||||
<pages:GroupingsPageListItemSelector x:Key="listItemDataTemplateSelector"
|
||||
HeaderTemplate="{StaticResource headerTemplate}"
|
||||
CipherTemplate="{StaticResource cipherTemplate}"
|
||||
AuthenticatorTemplate="{StaticResource authenticatorTemplate}"
|
||||
GroupTemplate="{StaticResource groupTemplate}" />
|
||||
|
||||
<StackLayout x:Key="mainLayout" x:Name="_mainLayout">
|
||||
<StackLayout
|
||||
IsVisible="{Binding ShowVaultFilter}"
|
||||
Orientation="Horizontal"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
Margin="0,5,0,0">
|
||||
<Label
|
||||
Text="{Binding VaultFilterDescription}"
|
||||
LineBreakMode="TailTruncation"
|
||||
Margin="10,0"
|
||||
StyleClass="text-md, text-muted"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="StartAndExpand"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Filter}" />
|
||||
<controls:MiButton
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.ViewCellMenu}}"
|
||||
StyleClass="list-row-button-text, list-row-button-platform"
|
||||
Command="{Binding VaultFilterCommand}"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="End"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n Filter}" />
|
||||
</StackLayout>
|
||||
<StackLayout
|
||||
VerticalOptions="CenterAndExpand"
|
||||
Padding="20, 0"
|
||||
Spacing="20"
|
||||
IsVisible="{Binding ShowNoData}">
|
||||
<Label
|
||||
Text="{Binding NoDataText}"
|
||||
HorizontalTextAlignment="Center"
|
||||
AutomationId="NoDataDisplayed"></Label>
|
||||
<Button
|
||||
Text="{u:I18n AddAnItem}"
|
||||
Clicked="AddButton_Clicked"
|
||||
IsVisible="{Binding ShowAddCipherButton}"></Button>
|
||||
</StackLayout>
|
||||
|
||||
<RefreshView
|
||||
IsVisible="{Binding ShowList}"
|
||||
IsRefreshing="{Binding Refreshing}"
|
||||
Command="{Binding RefreshCommand}">
|
||||
<controls:ExtendedCollectionView
|
||||
ItemsSource="{Binding GroupedItems}"
|
||||
VerticalOptions="FillAndExpand"
|
||||
ItemTemplate="{StaticResource listItemDataTemplateSelector}"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="RowSelected"
|
||||
StyleClass="list, list-platform"
|
||||
ExtraDataForLogging="Groupings Page" />
|
||||
</RefreshView>
|
||||
</StackLayout>
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<AbsoluteLayout
|
||||
x:Name="_absLayout"
|
||||
VerticalOptions="FillAndExpand"
|
||||
HorizontalOptions="FillAndExpand">
|
||||
<ContentView
|
||||
x:Name="_mainContent"
|
||||
AbsoluteLayout.LayoutFlags="All"
|
||||
AbsoluteLayout.LayoutBounds="0, 0, 1, 1">
|
||||
</ContentView>
|
||||
|
||||
<!-- Android FAB -->
|
||||
<Button
|
||||
x:Name="_fab"
|
||||
ImageSource="plus.png"
|
||||
Clicked="AddButton_Clicked"
|
||||
Style="{StaticResource btn-fab}"
|
||||
AbsoluteLayout.LayoutFlags="PositionProportional"
|
||||
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n AddItem}">
|
||||
<Button.Effects>
|
||||
<effects:FabShadowEffect />
|
||||
</Button.Effects>
|
||||
</Button>
|
||||
|
||||
<controls:AccountSwitchingOverlayView
|
||||
x:Name="_accountListOverlay"
|
||||
AbsoluteLayout.LayoutBounds="0, 0, 1, 1"
|
||||
AbsoluteLayout.LayoutFlags="All"
|
||||
MainPage="{Binding Source={x:Reference _page}}"
|
||||
MainFab="{Binding Source={x:Reference _fab}, Path=.}"
|
||||
BindingContext="{Binding AccountSwitchingOverlayViewModel}"/>
|
||||
</AbsoluteLayout>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
339
src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs
Normal file
339
src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Controls;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class GroupingsPage : BaseContentPage
|
||||
{
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
private readonly ISyncService _syncService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly GroupingsPageViewModel _vm;
|
||||
private readonly string _pageName;
|
||||
|
||||
private PreviousPageInfo _previousPage;
|
||||
|
||||
public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = null,
|
||||
string collectionId = null, string pageTitle = null, string vaultFilterSelection = null,
|
||||
PreviousPageInfo previousPage = null, bool deleted = false, bool showTotp = false)
|
||||
{
|
||||
_pageName = string.Concat(nameof(GroupingsPage), "_", DateTime.UtcNow.Ticks);
|
||||
InitializeComponent();
|
||||
SetActivityIndicator(_mainContent);
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
|
||||
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>("pushNotificationService");
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_vm = BindingContext as GroupingsPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.MainPage = mainPage;
|
||||
_vm.Type = type;
|
||||
_vm.FolderId = folderId;
|
||||
_vm.CollectionId = collectionId;
|
||||
_vm.Deleted = deleted;
|
||||
_vm.ShowTotp = showTotp;
|
||||
_previousPage = previousPage;
|
||||
if (pageTitle != null)
|
||||
{
|
||||
_vm.PageTitle = pageTitle;
|
||||
}
|
||||
if (vaultFilterSelection != null)
|
||||
{
|
||||
_vm.VaultFilterDescription = vaultFilterSelection;
|
||||
}
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
_absLayout.Children.Remove(_fab);
|
||||
ToolbarItems.Add(_addItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolbarItems.Add(_syncItem);
|
||||
ToolbarItems.Add(_lockItem);
|
||||
ToolbarItems.Add(_exitItem);
|
||||
}
|
||||
if (deleted || showTotp)
|
||||
{
|
||||
_absLayout.Children.Remove(_fab);
|
||||
ToolbarItems.Remove(_addItem);
|
||||
}
|
||||
if (!mainPage)
|
||||
{
|
||||
ToolbarItems.Remove(_accountAvatar);
|
||||
}
|
||||
}
|
||||
|
||||
protected async override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_syncService.SyncInProgress)
|
||||
{
|
||||
IsBusy = true;
|
||||
}
|
||||
|
||||
_accountAvatar?.OnAppearing();
|
||||
if (_vm.MainPage)
|
||||
{
|
||||
_vm.AvatarImageSource = await GetAvatarImageSourceAsync();
|
||||
}
|
||||
|
||||
_broadcasterService.Subscribe(_pageName, async (message) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (message.Command == "syncStarted")
|
||||
{
|
||||
Device.BeginInvokeOnMainThread(() => IsBusy = true);
|
||||
}
|
||||
else if (message.Command == "syncCompleted")
|
||||
{
|
||||
await Task.Delay(500);
|
||||
if (_vm.MainPage)
|
||||
{
|
||||
_vm.AvatarImageSource = await GetAvatarImageSourceAsync();
|
||||
}
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsBusy = false;
|
||||
if (_vm.LoadedOnce)
|
||||
{
|
||||
var task = _vm.LoadAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
});
|
||||
|
||||
await LoadOnAppearedAsync(_mainLayout, false, async () =>
|
||||
{
|
||||
if (!_syncService.SyncInProgress || (await _cipherService.GetAllAsync()).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
await _vm.LoadAsync();
|
||||
}
|
||||
catch (Exception e) when (e.Message.Contains("No key."))
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
await _vm.LoadAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
if (!_vm.Loaded)
|
||||
{
|
||||
await _vm.LoadAsync();
|
||||
}
|
||||
}
|
||||
await ShowPreviousPageAsync();
|
||||
AdjustToolbar();
|
||||
}, _mainContent);
|
||||
|
||||
if (!_vm.MainPage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Push registration
|
||||
var lastPushRegistration = await _stateService.GetPushLastRegistrationDateAsync();
|
||||
lastPushRegistration = lastPushRegistration.GetValueOrDefault(DateTime.MinValue);
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
var pushPromptShow = await _stateService.GetPushInitialPromptShownAsync();
|
||||
if (!pushPromptShow.GetValueOrDefault(false))
|
||||
{
|
||||
await _stateService.SetPushInitialPromptShownAsync(true);
|
||||
await DisplayAlert(AppResources.EnableAutomaticSyncing, AppResources.PushNotificationAlert,
|
||||
AppResources.OkGotIt);
|
||||
}
|
||||
if (!pushPromptShow.GetValueOrDefault(false) ||
|
||||
DateTime.UtcNow - lastPushRegistration > TimeSpan.FromDays(1))
|
||||
{
|
||||
await _pushNotificationService.RegisterAsync();
|
||||
}
|
||||
}
|
||||
else if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
if (DateTime.UtcNow - lastPushRegistration > TimeSpan.FromDays(1))
|
||||
{
|
||||
await _pushNotificationService.RegisterAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool OnBackButtonPressed()
|
||||
{
|
||||
if (_accountListOverlay.IsVisible)
|
||||
{
|
||||
_accountListOverlay.HideAsync().FireAndForget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
IsBusy = false;
|
||||
_vm.StopCiphersTotpTick().FireAndForget();
|
||||
_broadcasterService.Unsubscribe(_pageName);
|
||||
_vm.DisableRefreshing();
|
||||
_accountAvatar?.OnDisappearing();
|
||||
}
|
||||
|
||||
private async void RowSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
((ExtendedCollectionView)sender).SelectedItem = null;
|
||||
if (!DoOnce())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.CurrentSelection?.FirstOrDefault() is GroupingsPageTOTPListItem totpItem)
|
||||
{
|
||||
await _vm.SelectCipherAsync(totpItem.Cipher);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(e.CurrentSelection?.FirstOrDefault() is GroupingsPageListItem item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsTrash)
|
||||
{
|
||||
await _vm.SelectTrashAsync();
|
||||
}
|
||||
else if (item.IsTotpCode)
|
||||
{
|
||||
await _vm.SelectTotpCodesAsync();
|
||||
}
|
||||
else if (item.Cipher != null)
|
||||
{
|
||||
await _vm.SelectCipherAsync(item.Cipher);
|
||||
}
|
||||
else if (item.Folder != null)
|
||||
{
|
||||
await _vm.SelectFolderAsync(item.Folder);
|
||||
}
|
||||
else if (item.Collection != null)
|
||||
{
|
||||
await _vm.SelectCollectionAsync(item.Collection);
|
||||
}
|
||||
else if (item.Type != null)
|
||||
{
|
||||
await _vm.SelectTypeAsync(item.Type.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
_platformUtilsService.ShowDialogAsync(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok).FireAndForget();
|
||||
}
|
||||
}
|
||||
|
||||
private async void Search_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
await _accountListOverlay.HideAsync();
|
||||
if (DoOnce())
|
||||
{
|
||||
var page = new CiphersPage(_vm.Filter, _vm.MainPage ? null : _vm.PageTitle, deleted: _vm.Deleted);
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async void Sync_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
await _accountListOverlay.HideAsync();
|
||||
await _vm.SyncAsync();
|
||||
}
|
||||
|
||||
private async void Lock_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
await _accountListOverlay.HideAsync();
|
||||
await _vaultTimeoutService.LockAsync(true, true);
|
||||
}
|
||||
|
||||
private async void Exit_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
await _accountListOverlay.HideAsync();
|
||||
await _vm.ExitAsync();
|
||||
}
|
||||
|
||||
private async void AddButton_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
var skipAction = _accountListOverlay.IsVisible && Device.RuntimePlatform == Device.Android;
|
||||
await _accountListOverlay.HideAsync();
|
||||
if (skipAction)
|
||||
{
|
||||
// Account list in the process of closing via tapping on invisible FAB, skip this attempt
|
||||
return;
|
||||
}
|
||||
if (!_vm.Deleted && DoOnce())
|
||||
{
|
||||
var page = new CipherAddEditPage(null, _vm.Type, _vm.FolderId, _vm.CollectionId, _vm.GetVaultFilterOrgId());
|
||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowPreviousPageAsync()
|
||||
{
|
||||
if (_previousPage == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await _accountListOverlay.HideAsync();
|
||||
if (_previousPage.Page == "view" && !string.IsNullOrWhiteSpace(_previousPage.CipherId))
|
||||
{
|
||||
await Navigation.PushModalAsync(new NavigationPage(new CipherDetailsPage(_previousPage.CipherId)));
|
||||
}
|
||||
else if (_previousPage.Page == "edit" && !string.IsNullOrWhiteSpace(_previousPage.CipherId))
|
||||
{
|
||||
await Navigation.PushModalAsync(new NavigationPage(new CipherAddEditPage(_previousPage.CipherId)));
|
||||
}
|
||||
_previousPage = null;
|
||||
}
|
||||
|
||||
private void AdjustToolbar()
|
||||
{
|
||||
_addItem.IsEnabled = !_vm.Deleted;
|
||||
_addItem.IconImageSource = _vm.Deleted ? null : "plus.png";
|
||||
}
|
||||
|
||||
public async Task HideAccountSwitchingOverlayAsync()
|
||||
{
|
||||
await _accountListOverlay.HideAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Bit.App.Utilities.Automation;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageHeaderListItem : IGroupingsPageListItem
|
||||
{
|
||||
public GroupingsPageHeaderListItem(string title, string itemCount)
|
||||
{
|
||||
Title = title;
|
||||
ItemCount = itemCount;
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string ItemCount { get; set; }
|
||||
public string AutomationId
|
||||
{
|
||||
get
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor(AutomationIdsHelper.ToEnglishTitleCase(Title), SuffixType.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Core/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs
Normal file
38
src/Core/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
using Bit.App.Utilities.Automation;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageListGroup : List<IGroupingsPageListItem>
|
||||
{
|
||||
public GroupingsPageListGroup(string name, int count, bool doUpper = true, bool first = false)
|
||||
: this(new List<IGroupingsPageListItem>(), name, count, doUpper, first)
|
||||
{ }
|
||||
|
||||
public GroupingsPageListGroup(IEnumerable<IGroupingsPageListItem> groupItems, string name, int count,
|
||||
bool doUpper = true, bool first = false)
|
||||
{
|
||||
AddRange(groupItems);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
Name = "-";
|
||||
}
|
||||
else if (doUpper)
|
||||
{
|
||||
Name = name.ToUpperInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
ItemCount = count.ToString("N0");
|
||||
First = first;
|
||||
}
|
||||
|
||||
public bool First { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string NameShort => string.IsNullOrWhiteSpace(Name) || Name.Length == 0 ? "-" : Name[0].ToString();
|
||||
public string ItemCount { get; set; }
|
||||
public string AutomationId => AutomationIdsHelper.AddSuffixFor(NameShort, SuffixType.ListGroup);
|
||||
}
|
||||
}
|
||||
154
src/Core/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs
Normal file
154
src/Core/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities.Automation;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
using CollectionView = Bit.Core.Models.View.CollectionView;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageListItem : IGroupingsPageListItem
|
||||
{
|
||||
private string _icon;
|
||||
private string _name;
|
||||
|
||||
public FolderView Folder { get; set; }
|
||||
public CollectionView Collection { get; set; }
|
||||
public CipherView Cipher { get; set; }
|
||||
public CipherType? Type { get; set; }
|
||||
public string ItemCount { get; set; }
|
||||
public bool FuzzyAutofill { get; set; }
|
||||
public bool IsTrash { get; set; }
|
||||
public bool IsTotpCode { get; set; }
|
||||
|
||||
public string Name
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_name != null)
|
||||
{
|
||||
return _name;
|
||||
}
|
||||
if (IsTrash)
|
||||
{
|
||||
_name = AppResources.Trash;
|
||||
}
|
||||
else if (Folder != null)
|
||||
{
|
||||
_name = Folder.Name;
|
||||
}
|
||||
else if (Collection != null)
|
||||
{
|
||||
_name = Collection.Name;
|
||||
}
|
||||
else if (IsTotpCode)
|
||||
{
|
||||
_name = AppResources.VerificationCodes;
|
||||
}
|
||||
else if (Type != null)
|
||||
{
|
||||
switch (Type.Value)
|
||||
{
|
||||
case CipherType.Login:
|
||||
_name = AppResources.TypeLogin;
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
_name = AppResources.TypeSecureNote;
|
||||
break;
|
||||
case CipherType.Card:
|
||||
_name = AppResources.TypeCard;
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
_name = AppResources.TypeIdentity;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _name;
|
||||
}
|
||||
}
|
||||
|
||||
public string Icon
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_icon != null)
|
||||
{
|
||||
return _icon;
|
||||
}
|
||||
if (IsTrash)
|
||||
{
|
||||
_icon = BitwardenIcons.Trash;
|
||||
}
|
||||
else if (Folder != null)
|
||||
{
|
||||
_icon = BitwardenIcons.Folder;
|
||||
}
|
||||
else if (Collection != null)
|
||||
{
|
||||
_icon = BitwardenIcons.Collection;
|
||||
}
|
||||
else if (IsTotpCode)
|
||||
{
|
||||
_icon = BitwardenIcons.Clock;
|
||||
}
|
||||
else if (Type != null)
|
||||
{
|
||||
switch (Type.Value)
|
||||
{
|
||||
case CipherType.Login:
|
||||
_icon = BitwardenIcons.Globe;
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
_icon = BitwardenIcons.StickyNote;
|
||||
break;
|
||||
case CipherType.Card:
|
||||
_icon = BitwardenIcons.CreditCard;
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
_icon = BitwardenIcons.IdCard;
|
||||
break;
|
||||
default:
|
||||
_icon = BitwardenIcons.Globe;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _icon;
|
||||
}
|
||||
}
|
||||
|
||||
public string AutomationId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Type != null)
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor(System.Enum.GetName(typeof(CipherType), Type.Value), SuffixType.Filter);
|
||||
}
|
||||
|
||||
if (IsTrash)
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor("Trash", SuffixType.Filter);
|
||||
}
|
||||
|
||||
if (Folder != null)
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor("Folder", SuffixType.Filter);
|
||||
}
|
||||
|
||||
if (Collection != null)
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor("Collection", SuffixType.Filter);
|
||||
}
|
||||
|
||||
if (IsTotpCode)
|
||||
{
|
||||
return AutomationIdsHelper.AddSuffixFor("TOTP", SuffixType.ListItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageListItemSelector : DataTemplateSelector
|
||||
{
|
||||
public DataTemplate HeaderTemplate { get; set; }
|
||||
public DataTemplate CipherTemplate { get; set; }
|
||||
public DataTemplate GroupTemplate { get; set; }
|
||||
public DataTemplate AuthenticatorTemplate { get; set; }
|
||||
|
||||
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
|
||||
{
|
||||
if (item is GroupingsPageHeaderListItem)
|
||||
{
|
||||
return HeaderTemplate;
|
||||
}
|
||||
|
||||
if (item is GroupingsPageTOTPListItem)
|
||||
{
|
||||
return AuthenticatorTemplate;
|
||||
}
|
||||
|
||||
if (item is GroupingsPageListItem listItem)
|
||||
{
|
||||
return listItem.Cipher != null ? CipherTemplate : GroupTemplate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Core/Pages/Vault/GroupingsPage/GroupingsPageTOTPListItem.cs
Normal file
122
src/Core/Pages/Vault/GroupingsPage/GroupingsPageTOTPListItem.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageTOTPListItem : ExtendedViewModel, IGroupingsPageListItem
|
||||
{
|
||||
private readonly LazyResolve<ILogger> _logger = new LazyResolve<ILogger>("logger");
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private CipherView _cipher;
|
||||
|
||||
private bool _websiteIconsEnabled;
|
||||
private string _iconImageSource = string.Empty;
|
||||
|
||||
private double _progress;
|
||||
private string _totpSec;
|
||||
private string _totpCodeFormatted;
|
||||
private TotpHelper _totpTickHelper;
|
||||
|
||||
|
||||
public GroupingsPageTOTPListItem(CipherView cipherView, bool websiteIconsEnabled)
|
||||
{
|
||||
_totpService = ServiceContainer.Resolve<ITotpService>("totpService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
|
||||
Cipher = cipherView;
|
||||
WebsiteIconsEnabled = websiteIconsEnabled;
|
||||
CopyCommand = new AsyncCommand(CopyToClipboardAsync,
|
||||
onException: ex => _logger.Value.Exception(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
_totpTickHelper = new TotpHelper(cipherView);
|
||||
}
|
||||
|
||||
public AsyncCommand CopyCommand { get; set; }
|
||||
|
||||
public CipherView Cipher
|
||||
{
|
||||
get => _cipher;
|
||||
set => SetProperty(ref _cipher, value);
|
||||
}
|
||||
|
||||
public string TotpCodeFormatted
|
||||
{
|
||||
get => _totpCodeFormatted;
|
||||
set => SetProperty(ref _totpCodeFormatted, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(TotpCodeFormattedStart),
|
||||
nameof(TotpCodeFormattedEnd),
|
||||
});
|
||||
}
|
||||
|
||||
public string TotpSec
|
||||
{
|
||||
get => _totpSec;
|
||||
set => SetProperty(ref _totpSec, value);
|
||||
}
|
||||
public double Progress
|
||||
{
|
||||
get => _progress;
|
||||
set => SetProperty(ref _progress, value);
|
||||
}
|
||||
public bool WebsiteIconsEnabled
|
||||
{
|
||||
get => _websiteIconsEnabled;
|
||||
set => SetProperty(ref _websiteIconsEnabled, value);
|
||||
}
|
||||
|
||||
public bool ShowIconImage
|
||||
{
|
||||
get => WebsiteIconsEnabled
|
||||
&& !string.IsNullOrWhiteSpace(Cipher.Login?.Uri)
|
||||
&& IconImageSource != null;
|
||||
}
|
||||
|
||||
public string IconImageSource
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_iconImageSource == string.Empty) // default value since icon source can return null
|
||||
{
|
||||
_iconImageSource = IconImageHelper.GetLoginIconImage(Cipher);
|
||||
}
|
||||
return _iconImageSource;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public string TotpCodeFormattedStart => TotpCodeFormatted?.Split(' ')[0];
|
||||
|
||||
public string TotpCodeFormattedEnd => TotpCodeFormatted?.Split(' ')[1];
|
||||
|
||||
public async Task CopyToClipboardAsync()
|
||||
{
|
||||
await _clipboardService.CopyTextAsync(TotpCodeFormatted?.Replace(" ", string.Empty));
|
||||
_platformUtilsService.ShowToast("info", null, string.Format(AppResources.ValueHasBeenCopied, AppResources.VerificationCodeTotp));
|
||||
}
|
||||
|
||||
public async Task TotpTickAsync()
|
||||
{
|
||||
await _totpTickHelper.GenerateNewTotpValues();
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
TotpSec = _totpTickHelper.TotpSec;
|
||||
Progress = _totpTickHelper.Progress;
|
||||
TotpCodeFormatted = _totpTickHelper.TotpCodeFormatted;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
708
src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
Normal file
708
src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
Normal file
@@ -0,0 +1,708 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Controls;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class GroupingsPageViewModel : VaultFilterViewModel
|
||||
{
|
||||
private const int NoFolderListSize = 100;
|
||||
|
||||
private bool _refreshing;
|
||||
private bool _doingLoad;
|
||||
private bool _loading;
|
||||
private bool _loaded;
|
||||
private bool _showAddCipherButton;
|
||||
private bool _showNoData;
|
||||
private bool _showList;
|
||||
private bool _websiteIconsEnabled;
|
||||
private bool _syncRefreshing;
|
||||
private bool _showTotpFilter;
|
||||
private bool _totpFilterEnable;
|
||||
private string _noDataText;
|
||||
private List<CipherView> _allCiphers;
|
||||
private Dictionary<string, int> _folderCounts = new Dictionary<string, int>();
|
||||
private Dictionary<string, int> _collectionCounts = new Dictionary<string, int>();
|
||||
private Dictionary<CipherType, int> _typeCounts = new Dictionary<CipherType, int>();
|
||||
private int _deletedCount = 0;
|
||||
private CancellationTokenSource _totpTickCts;
|
||||
private Task _totpTickTask;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly ISyncService _syncService;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly IMessagingService _messagingService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IPasswordRepromptService _passwordRepromptService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public GroupingsPageViewModel()
|
||||
{
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_folderService = ServiceContainer.Resolve<IFolderService>("folderService");
|
||||
_collectionService = ServiceContainer.Resolve<ICollectionService>("collectionService");
|
||||
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
|
||||
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
|
||||
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
|
||||
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
|
||||
_policyService = ServiceContainer.Resolve<IPolicyService>("policyService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>("logger");
|
||||
|
||||
Loading = true;
|
||||
GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>();
|
||||
RefreshCommand = new Command(async () =>
|
||||
{
|
||||
Refreshing = true;
|
||||
await LoadAsync();
|
||||
});
|
||||
CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService),
|
||||
onException: ex => _logger.Exception(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
VaultFilterCommand = new AsyncCommand(VaultFilterOptionsAsync,
|
||||
onException: ex => _logger.Exception(ex),
|
||||
allowsMultipleExecutions: false);
|
||||
|
||||
AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger)
|
||||
{
|
||||
AllowAddAccountRow = true
|
||||
};
|
||||
}
|
||||
|
||||
public bool MainPage { get; set; }
|
||||
public CipherType? Type { get; set; }
|
||||
public string FolderId { get; set; }
|
||||
public string CollectionId { get; set; }
|
||||
public Func<CipherView, bool> Filter { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public bool HasCiphers { get; set; }
|
||||
public bool HasFolders { get; set; }
|
||||
public bool HasCollections { get; set; }
|
||||
public bool ShowNoFolderCipherGroup => NoFolderCiphers != null
|
||||
&& NoFolderCiphers.Count < NoFolderListSize
|
||||
&& (Collections is null || !Collections.Any());
|
||||
public List<CipherView> Ciphers { get; set; }
|
||||
public List<CipherView> TOTPCiphers { get; set; }
|
||||
public List<CipherView> FavoriteCiphers { get; set; }
|
||||
public List<CipherView> NoFolderCiphers { get; set; }
|
||||
public List<FolderView> Folders { get; set; }
|
||||
public List<TreeNode<FolderView>> NestedFolders { get; set; }
|
||||
public List<Core.Models.View.CollectionView> Collections { get; set; }
|
||||
public List<TreeNode<Core.Models.View.CollectionView>> NestedCollections { get; set; }
|
||||
|
||||
protected override ICipherService cipherService => _cipherService;
|
||||
protected override IPolicyService policyService => _policyService;
|
||||
protected override IOrganizationService organizationService => _organizationService;
|
||||
protected override ILogger logger => _logger;
|
||||
|
||||
public bool Refreshing
|
||||
{
|
||||
get => _refreshing;
|
||||
set => SetProperty(ref _refreshing, value);
|
||||
}
|
||||
public bool SyncRefreshing
|
||||
{
|
||||
get => _syncRefreshing;
|
||||
set => SetProperty(ref _syncRefreshing, value);
|
||||
}
|
||||
public bool Loading
|
||||
{
|
||||
get => _loading;
|
||||
set => SetProperty(ref _loading, value);
|
||||
}
|
||||
public bool Loaded
|
||||
{
|
||||
get => _loaded;
|
||||
set => SetProperty(ref _loaded, value);
|
||||
}
|
||||
public bool ShowAddCipherButton
|
||||
{
|
||||
get => _showAddCipherButton;
|
||||
set => SetProperty(ref _showAddCipherButton, value);
|
||||
}
|
||||
public bool ShowNoData
|
||||
{
|
||||
get => _showNoData;
|
||||
set => SetProperty(ref _showNoData, value);
|
||||
}
|
||||
public string NoDataText
|
||||
{
|
||||
get => _noDataText;
|
||||
set => SetProperty(ref _noDataText, value);
|
||||
}
|
||||
public bool ShowList
|
||||
{
|
||||
get => _showList;
|
||||
set => SetProperty(ref _showList, value);
|
||||
}
|
||||
public bool WebsiteIconsEnabled
|
||||
{
|
||||
get => _websiteIconsEnabled;
|
||||
set => SetProperty(ref _websiteIconsEnabled, value);
|
||||
}
|
||||
public bool ShowTotp
|
||||
{
|
||||
get => _showTotpFilter;
|
||||
set => SetProperty(ref _showTotpFilter, value);
|
||||
}
|
||||
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
|
||||
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
|
||||
public Command RefreshCommand { get; set; }
|
||||
public ICommand CipherOptionsCommand { get; }
|
||||
public bool LoadedOnce { get; set; }
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
if (_doingLoad)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var authed = await _stateService.IsAuthenticatedAsync();
|
||||
if (!authed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (await _vaultTimeoutService.IsLockedAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (await _stateService.GetSyncOnRefreshAsync() && Refreshing && !SyncRefreshing)
|
||||
{
|
||||
SyncRefreshing = true;
|
||||
await _syncService.SyncPasswordlessLoginRequestsAsync();
|
||||
await _syncService.FullSyncAsync(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_deviceActionService.SetScreenCaptureAllowedAsync().FireAndForget();
|
||||
|
||||
await InitVaultFilterAsync(MainPage);
|
||||
if (MainPage)
|
||||
{
|
||||
PageTitle = ShowVaultFilter ? AppResources.Vaults : AppResources.MyVault;
|
||||
}
|
||||
_doingLoad = true;
|
||||
LoadedOnce = true;
|
||||
ShowNoData = false;
|
||||
Loading = true;
|
||||
ShowList = false;
|
||||
ShowAddCipherButton = !Deleted;
|
||||
|
||||
var groupedItems = new List<GroupingsPageListGroup>();
|
||||
var page = Page as GroupingsPage;
|
||||
|
||||
WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault();
|
||||
try
|
||||
{
|
||||
await LoadDataAsync();
|
||||
if (ShowNoFolderCipherGroup && (NestedFolders?.Any() ?? false))
|
||||
{
|
||||
// Remove "No Folder" folder from folders group
|
||||
NestedFolders = NestedFolders.GetRange(0, NestedFolders.Count - 1);
|
||||
}
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
var uppercaseGroupNames = Device.RuntimePlatform == Device.iOS;
|
||||
var hasFavorites = FavoriteCiphers?.Any() ?? false;
|
||||
if (hasFavorites)
|
||||
{
|
||||
var favListItems = FavoriteCiphers.Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(favListItems, AppResources.Favorites,
|
||||
favListItems.Count, uppercaseGroupNames, true));
|
||||
}
|
||||
if (MainPage)
|
||||
{
|
||||
AddTotpGroupItem(groupedItems, uppercaseGroupNames);
|
||||
|
||||
var types = new CipherType[] { CipherType.Login, CipherType.Card, CipherType.Identity, CipherType.SecureNote };
|
||||
var typesGroup = new GroupingsPageListGroup(AppResources.Types, types.Length, uppercaseGroupNames, !hasFavorites);
|
||||
foreach (CipherType t in types)
|
||||
{
|
||||
typesGroup.Add(new GroupingsPageListItem
|
||||
{
|
||||
Type = t,
|
||||
ItemCount = _typeCounts.GetValueOrDefault(t).ToString("N0")
|
||||
});
|
||||
}
|
||||
groupedItems.Add(typesGroup);
|
||||
}
|
||||
if (NestedFolders?.Any() ?? false)
|
||||
{
|
||||
var folderListItems = NestedFolders.Select(f =>
|
||||
{
|
||||
var fId = f.Node.Id ?? "none";
|
||||
return new GroupingsPageListItem
|
||||
{
|
||||
Folder = f.Node,
|
||||
ItemCount = (_folderCounts.ContainsKey(fId) ? _folderCounts[fId] : 0).ToString("N0")
|
||||
};
|
||||
}).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(folderListItems, AppResources.Folders,
|
||||
folderListItems.Count, uppercaseGroupNames, !MainPage));
|
||||
}
|
||||
if (NestedCollections?.Any() ?? false)
|
||||
{
|
||||
var collectionListItems = NestedCollections.Select(c => new GroupingsPageListItem
|
||||
{
|
||||
Collection = c.Node,
|
||||
ItemCount = (_collectionCounts.ContainsKey(c.Node.Id) ?
|
||||
_collectionCounts[c.Node.Id] : 0).ToString("N0")
|
||||
}).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(collectionListItems, AppResources.Collections,
|
||||
collectionListItems.Count, uppercaseGroupNames, !MainPage));
|
||||
}
|
||||
if (Ciphers?.Any() ?? false)
|
||||
{
|
||||
CreateCipherGroupedItems(groupedItems);
|
||||
}
|
||||
if (ShowTotp && (!TOTPCiphers?.Any() ?? false))
|
||||
{
|
||||
Page.Navigation.PopAsync();
|
||||
return;
|
||||
}
|
||||
if (ShowNoFolderCipherGroup)
|
||||
{
|
||||
var noFolderCiphersListItems = NoFolderCiphers.Select(
|
||||
c => new GroupingsPageListItem { Cipher = c }).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(noFolderCiphersListItems, AppResources.FolderNone,
|
||||
noFolderCiphersListItems.Count, uppercaseGroupNames, false));
|
||||
}
|
||||
// Ensure this is last in the list (appears at the bottom)
|
||||
if (MainPage && !Deleted)
|
||||
{
|
||||
groupedItems.Add(new GroupingsPageListGroup(new List<GroupingsPageListItem>()
|
||||
{
|
||||
new GroupingsPageListItem()
|
||||
{
|
||||
IsTrash = true,
|
||||
ItemCount = _deletedCount.ToString("N0")
|
||||
}
|
||||
}, AppResources.Trash, _deletedCount, uppercaseGroupNames, false));
|
||||
}
|
||||
|
||||
// TODO: refactor this
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android
|
||||
||
|
||||
GroupedItems.Any())
|
||||
{
|
||||
var items = new List<IGroupingsPageListItem>();
|
||||
foreach (var itemGroup in groupedItems)
|
||||
{
|
||||
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
|
||||
items.AddRange(itemGroup);
|
||||
}
|
||||
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
// HACK: [PS-536] Fix to avoid blank list after back navigation on unlocking with previous page info
|
||||
// because of update to XF v5.0.0.2401
|
||||
GroupedItems.Clear();
|
||||
}
|
||||
GroupedItems.ReplaceRange(items);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list
|
||||
var first = true;
|
||||
var items = new List<IGroupingsPageListItem>();
|
||||
foreach (var itemGroup in groupedItems)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
|
||||
}
|
||||
else
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
items.AddRange(itemGroup);
|
||||
}
|
||||
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
if (groupedItems.Any())
|
||||
{
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
// HACK: [PS-536] Fix to avoid blank list after back navigation on unlocking with previous page info
|
||||
// because of update to XF v5.0.0.2401
|
||||
GroupedItems.Clear();
|
||||
}
|
||||
GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) });
|
||||
GroupedItems.AddRange(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
GroupedItems.Clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_doingLoad = false;
|
||||
Loaded = true;
|
||||
Loading = false;
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
ShowNoData = (MainPage && !HasCiphers) || !groupedItems.Any();
|
||||
ShowList = !ShowNoData;
|
||||
DisableRefreshing();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void AddTotpGroupItem(List<GroupingsPageListGroup> groupedItems, bool uppercaseGroupNames)
|
||||
{
|
||||
if (TOTPCiphers?.Any() == true)
|
||||
{
|
||||
groupedItems.Insert(0, new GroupingsPageListGroup(
|
||||
AppResources.Totp, 1, uppercaseGroupNames, false)
|
||||
{
|
||||
new GroupingsPageListItem
|
||||
{
|
||||
IsTotpCode = true,
|
||||
Type = CipherType.Login,
|
||||
ItemCount = TOTPCiphers.Count().ToString("N0")
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateCipherGroupedItems(List<GroupingsPageListGroup> groupedItems)
|
||||
{
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
var uppercaseGroupNames = Device.RuntimePlatform == Device.iOS;
|
||||
_totpTickCts?.Cancel();
|
||||
if (ShowTotp)
|
||||
{
|
||||
var ciphersListItems = TOTPCiphers.Select(c => new GroupingsPageTOTPListItem(c, true)).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
|
||||
ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
|
||||
|
||||
StartCiphersTotpTick(ciphersListItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
var ciphersListItems = Ciphers.Where(c => c.IsDeleted == Deleted)
|
||||
.Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
|
||||
groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
|
||||
ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
|
||||
}
|
||||
}
|
||||
|
||||
private void StartCiphersTotpTick(List<GroupingsPageTOTPListItem> ciphersListItems)
|
||||
{
|
||||
_totpTickCts?.Cancel();
|
||||
_totpTickCts = new CancellationTokenSource();
|
||||
_totpTickTask = new TimerTask(logger, () => ciphersListItems.ForEach(i => i.TotpTickAsync()), _totpTickCts).RunPeriodic();
|
||||
}
|
||||
|
||||
public async Task StopCiphersTotpTick()
|
||||
{
|
||||
_totpTickCts?.Cancel();
|
||||
if (_totpTickTask != null)
|
||||
{
|
||||
await _totpTickTask;
|
||||
}
|
||||
}
|
||||
|
||||
public void DisableRefreshing()
|
||||
{
|
||||
Refreshing = false;
|
||||
SyncRefreshing = false;
|
||||
}
|
||||
|
||||
protected override async Task OnVaultFilterSelectedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
public async Task SelectCipherAsync(CipherView cipher)
|
||||
{
|
||||
var page = new CipherDetailsPage(cipher.Id);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(page));
|
||||
}
|
||||
|
||||
public async Task SelectTypeAsync(CipherType type)
|
||||
{
|
||||
string title = null;
|
||||
switch (type)
|
||||
{
|
||||
case CipherType.Login:
|
||||
title = AppResources.Logins;
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
title = AppResources.SecureNotes;
|
||||
break;
|
||||
case CipherType.Card:
|
||||
title = AppResources.Cards;
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
title = AppResources.Identities;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
var page = new GroupingsPage(false, type, null, null, title, _vaultFilterSelection);
|
||||
await Page.Navigation.PushAsync(page);
|
||||
}
|
||||
|
||||
public async Task SelectFolderAsync(FolderView folder)
|
||||
{
|
||||
var page = new GroupingsPage(false, null, folder.Id ?? "none", null, folder.Name, _vaultFilterSelection);
|
||||
await Page.Navigation.PushAsync(page);
|
||||
}
|
||||
|
||||
public async Task SelectCollectionAsync(Core.Models.View.CollectionView collection)
|
||||
{
|
||||
var page = new GroupingsPage(false, null, null, collection.Id, collection.Name, _vaultFilterSelection);
|
||||
await Page.Navigation.PushAsync(page);
|
||||
}
|
||||
|
||||
public async Task SelectTrashAsync()
|
||||
{
|
||||
var page = new GroupingsPage(false, null, null, null, AppResources.Trash, _vaultFilterSelection, null,
|
||||
true);
|
||||
await Page.Navigation.PushAsync(page);
|
||||
}
|
||||
|
||||
public async Task SelectTotpCodesAsync()
|
||||
{
|
||||
var page = new GroupingsPage(false, CipherType.Login, null, null, AppResources.VerificationCodes, _vaultFilterSelection, null,
|
||||
false, true);
|
||||
await Page.Navigation.PushAsync(page);
|
||||
}
|
||||
|
||||
public async Task ExitAsync()
|
||||
{
|
||||
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.ExitConfirmation,
|
||||
AppResources.Exit, AppResources.Yes, AppResources.Cancel);
|
||||
if (confirmed)
|
||||
{
|
||||
_messagingService.Send("exit");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SyncAsync()
|
||||
{
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return;
|
||||
}
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Syncing);
|
||||
try
|
||||
{
|
||||
await _syncService.FullSyncAsync(false, true);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("success", null, AppResources.SyncingComplete);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
_platformUtilsService.ShowToast("error", null, AppResources.SyncingFailed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
var canAccessPremium = await _stateService.CanAccessPremiumAsync();
|
||||
NoDataText = AppResources.NoItems;
|
||||
_allCiphers = await GetAllCiphersAsync();
|
||||
HasCiphers = _allCiphers.Any();
|
||||
TOTPCiphers = _allCiphers.Where(c => c.IsDeleted == Deleted && c.Type == CipherType.Login && !string.IsNullOrEmpty(c.Login?.Totp) && (c.OrganizationUseTotp || canAccessPremium)).ToList();
|
||||
FavoriteCiphers?.Clear();
|
||||
NoFolderCiphers?.Clear();
|
||||
_folderCounts.Clear();
|
||||
_collectionCounts.Clear();
|
||||
_typeCounts.Clear();
|
||||
HasFolders = false;
|
||||
HasCollections = false;
|
||||
Filter = null;
|
||||
_deletedCount = 0;
|
||||
|
||||
if (MainPage)
|
||||
{
|
||||
await FillFoldersAndCollectionsAsync();
|
||||
NestedFolders = await _folderService.GetAllNestedAsync(Folders);
|
||||
HasFolders = NestedFolders.Any(f => f.Node?.Id != null);
|
||||
NestedCollections = Collections != null ? await _collectionService.GetAllNestedAsync(Collections) : null;
|
||||
HasCollections = NestedCollections?.Any() ?? false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Deleted)
|
||||
{
|
||||
Filter = c => c.IsDeleted;
|
||||
NoDataText = AppResources.NoItemsTrash;
|
||||
}
|
||||
else if (ShowTotp)
|
||||
{
|
||||
Filter = c => c.Type == CipherType.Login && !c.IsDeleted && !string.IsNullOrEmpty(c.Login?.Totp);
|
||||
}
|
||||
else if (Type != null)
|
||||
{
|
||||
Filter = c => !c.IsDeleted
|
||||
&&
|
||||
Type.Value == c.Type;
|
||||
}
|
||||
else if (FolderId != null)
|
||||
{
|
||||
NoDataText = AppResources.NoItemsFolder;
|
||||
var folderId = FolderId == "none" ? null : FolderId;
|
||||
if (folderId != null)
|
||||
{
|
||||
var folderNode = await _folderService.GetNestedAsync(folderId);
|
||||
if (folderNode?.Node != null)
|
||||
{
|
||||
PageTitle = folderNode.Node.Name;
|
||||
NestedFolders = (folderNode.Children?.Count ?? 0) > 0 ? folderNode.Children : null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PageTitle = AppResources.FolderNone;
|
||||
}
|
||||
Filter = c => c.FolderId == folderId && !c.IsDeleted;
|
||||
}
|
||||
else if (CollectionId != null)
|
||||
{
|
||||
ShowAddCipherButton = false;
|
||||
NoDataText = AppResources.NoItemsCollection;
|
||||
var collectionNode = await _collectionService.GetNestedAsync(CollectionId);
|
||||
if (collectionNode?.Node != null)
|
||||
{
|
||||
PageTitle = collectionNode.Node.Name;
|
||||
NestedCollections = (collectionNode.Children?.Count ?? 0) > 0 ? collectionNode.Children : null;
|
||||
}
|
||||
Filter = c => c.CollectionIds?.Contains(CollectionId) ?? false && !c.IsDeleted;
|
||||
}
|
||||
else
|
||||
{
|
||||
PageTitle = AppResources.AllItems;
|
||||
}
|
||||
Ciphers = Filter != null ? _allCiphers.Where(Filter).ToList() : _allCiphers;
|
||||
}
|
||||
|
||||
foreach (var c in _allCiphers)
|
||||
{
|
||||
if (MainPage)
|
||||
{
|
||||
if (c.IsDeleted)
|
||||
{
|
||||
_deletedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.Favorite)
|
||||
{
|
||||
if (FavoriteCiphers == null)
|
||||
{
|
||||
FavoriteCiphers = new List<CipherView>();
|
||||
}
|
||||
FavoriteCiphers.Add(c);
|
||||
}
|
||||
if (c.FolderId == null)
|
||||
{
|
||||
if (NoFolderCiphers == null)
|
||||
{
|
||||
NoFolderCiphers = new List<CipherView>();
|
||||
}
|
||||
NoFolderCiphers.Add(c);
|
||||
}
|
||||
|
||||
_typeCounts[c.Type] = _typeCounts.TryGetValue(c.Type, out var currentTypeCount)
|
||||
? currentTypeCount + 1
|
||||
: 1;
|
||||
}
|
||||
|
||||
if (c.IsDeleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fId = c.FolderId ?? "none";
|
||||
if (_folderCounts.ContainsKey(fId))
|
||||
{
|
||||
_folderCounts[fId] = _folderCounts[fId] + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_folderCounts.Add(fId, 1);
|
||||
}
|
||||
|
||||
if (c.CollectionIds != null)
|
||||
{
|
||||
foreach (var colId in c.CollectionIds)
|
||||
{
|
||||
if (_collectionCounts.ContainsKey(colId))
|
||||
{
|
||||
_collectionCounts[colId] = _collectionCounts[colId] + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_collectionCounts.Add(colId, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FillFoldersAndCollectionsAsync()
|
||||
{
|
||||
var orgId = GetVaultFilterOrgId();
|
||||
var decFolders = await _folderService.GetAllDecryptedAsync();
|
||||
var decCollections = await _collectionService.GetAllDecryptedAsync();
|
||||
if (IsVaultFilterMyVault)
|
||||
{
|
||||
Folders = BuildFolders(decFolders);
|
||||
Collections = null;
|
||||
}
|
||||
else if (IsVaultFilterOrgVault && !string.IsNullOrWhiteSpace(orgId))
|
||||
{
|
||||
Folders = BuildFolders(decFolders);
|
||||
Collections = decCollections?.Where(c => c.OrganizationId == orgId).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
Folders = decFolders;
|
||||
Collections = decCollections;
|
||||
}
|
||||
}
|
||||
|
||||
private List<FolderView> BuildFolders(List<FolderView> decFolders)
|
||||
{
|
||||
var folders = decFolders.Where(f => _allCiphers.Any(c => c.FolderId == f.Id)).ToList();
|
||||
return folders.Any() ? folders : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public interface IGroupingsPageListItem
|
||||
{
|
||||
}
|
||||
}
|
||||
80
src/Core/Pages/Vault/OTPCipherSelectionPageViewModel.cs
Normal file
80
src/Core/Pages/Vault/OTPCipherSelectionPageViewModel.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class OTPCipherSelectionPageViewModel : CipherSelectionPageViewModel
|
||||
{
|
||||
private readonly ISearchService _searchService = ServiceContainer.Resolve<ISearchService>();
|
||||
|
||||
private OtpData _otpData;
|
||||
private Models.AppOptions _appOptions;
|
||||
|
||||
public override bool ShowCallout => !ShowNoData;
|
||||
|
||||
public override void Init(Models.AppOptions options)
|
||||
{
|
||||
_appOptions = options;
|
||||
_otpData = options.OtpData.Value;
|
||||
|
||||
Name = _otpData.Issuer ?? _otpData.AccountName;
|
||||
PageTitle = string.Format(AppResources.ItemsForUri, Name ?? "--");
|
||||
NoDataText = string.Format(AppResources.ThereAreNoItemsInYourVaultThatMatchX, Name ?? "--")
|
||||
+ Environment.NewLine
|
||||
+ AppResources.SearchForAnItemOrAddANewItem;
|
||||
}
|
||||
|
||||
protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync()
|
||||
{
|
||||
var groupedItems = new List<GroupingsPageListGroup>();
|
||||
var allCiphers = await _cipherService.GetAllDecryptedAsync();
|
||||
var ciphers = await _searchService.SearchCiphersAsync(_otpData.Issuer ?? _otpData.AccountName,
|
||||
c => c.Type == CipherType.Login && !c.IsDeleted, allCiphers);
|
||||
|
||||
if (ciphers?.Any() ?? false)
|
||||
{
|
||||
groupedItems.Add(
|
||||
new GroupingsPageListGroup(ciphers.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(),
|
||||
AppResources.MatchingItems,
|
||||
ciphers.Count,
|
||||
false,
|
||||
true));
|
||||
}
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
protected override async Task SelectCipherAsync(IGroupingsPageListItem item)
|
||||
{
|
||||
if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cipher = listItem.Cipher;
|
||||
|
||||
if (!await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(cipher.Reprompt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage));
|
||||
return;
|
||||
}
|
||||
|
||||
protected override async Task AddCipherAsync()
|
||||
{
|
||||
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: Name, appOptions: _appOptions);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/Core/Pages/Vault/PasswordHistoryPage.xaml
Normal file
87
src/Core/Pages/Vault/PasswordHistoryPage.xaml
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.PasswordHistoryPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:views="clr-namespace:Bit.Core.Models.View"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
x:DataType="pages:PasswordHistoryPageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:PasswordHistoryPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:DateTimeConverter x:Key="dateTime" />
|
||||
<u:ColoredPasswordConverter x:Key="coloredPassword" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<StackLayout x:Name="_mainLayout">
|
||||
<Label IsVisible="{Binding ShowNoData}"
|
||||
Text="{u:I18n NoPasswordsToList}"
|
||||
Margin="20, 0"
|
||||
VerticalOptions="CenterAndExpand"
|
||||
HorizontalOptions="CenterAndExpand"
|
||||
HorizontalTextAlignment="Center"></Label>
|
||||
<controls:ExtendedCollectionView
|
||||
IsVisible="{Binding ShowNoData, Converter={StaticResource inverseBool}}"
|
||||
ItemsSource="{Binding History}"
|
||||
VerticalOptions="FillAndExpand"
|
||||
StyleClass="list, list-platform"
|
||||
ExtraDataForLogging="Password History Page">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:PasswordHistoryView">
|
||||
<Grid
|
||||
StyleClass="list-row, list-row-platform"
|
||||
Padding="10"
|
||||
RowSpacing="0"
|
||||
ColumnSpacing="10">
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<controls:MonoLabel LineBreakMode="CharacterWrap"
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
StyleClass="list-title, list-title-platform, text-html"
|
||||
Text="{Binding Password, Mode=OneWay, Converter={StaticResource coloredPassword}}" />
|
||||
<Label LineBreakMode="TailTruncation"
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
StyleClass="list-subtitle, list-subtitle-platform"
|
||||
Text="{Binding LastUsedDate, Mode=OneWay, Converter={StaticResource dateTime}}" />
|
||||
<controls:IconButton
|
||||
StyleClass="list-row-button, list-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
|
||||
Command="{Binding BindingContext.CopyCommand, Source={x:Reference _page}}"
|
||||
CommandParameter="{Binding .}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
SemanticProperties.Description="{u:I18n CopyPassword}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</controls:ExtendedCollectionView>
|
||||
</StackLayout>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
42
src/Core/Pages/Vault/PasswordHistoryPage.xaml.cs
Normal file
42
src/Core/Pages/Vault/PasswordHistoryPage.xaml.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class PasswordHistoryPage : BaseContentPage
|
||||
{
|
||||
private PasswordHistoryPageViewModel _vm;
|
||||
|
||||
public PasswordHistoryPage(string cipherId)
|
||||
{
|
||||
InitializeComponent();
|
||||
SetActivityIndicator();
|
||||
_vm = BindingContext as PasswordHistoryPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await LoadOnAppearedAsync(_mainLayout, true, async () =>
|
||||
{
|
||||
await _vm.InitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Core/Pages/Vault/PasswordHistoryPageViewModel.cs
Normal file
58
src/Core/Pages/Vault/PasswordHistoryPageViewModel.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class PasswordHistoryPageViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
|
||||
private bool _showNoData;
|
||||
|
||||
public PasswordHistoryPageViewModel()
|
||||
{
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
|
||||
PageTitle = AppResources.PasswordHistory;
|
||||
History = new ExtendedObservableCollection<PasswordHistoryView>();
|
||||
CopyCommand = new Command<PasswordHistoryView>(CopyAsync);
|
||||
}
|
||||
|
||||
public Command CopyCommand { get; set; }
|
||||
public string CipherId { get; set; }
|
||||
public ExtendedObservableCollection<PasswordHistoryView> History { get; set; }
|
||||
|
||||
public bool ShowNoData
|
||||
{
|
||||
get => _showNoData;
|
||||
set => SetProperty(ref _showNoData, value);
|
||||
}
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
var cipher = await _cipherService.GetAsync(CipherId);
|
||||
var decCipher = await cipher.DecryptAsync();
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
History.ResetWithRange(decCipher.PasswordHistory ?? new List<PasswordHistoryView>());
|
||||
ShowNoData = History.Count == 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async void CopyAsync(PasswordHistoryView ph)
|
||||
{
|
||||
await _clipboardService.CopyTextAsync(ph.Password);
|
||||
_platformUtilsService.ShowToastForCopiedValue(AppResources.Password);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/Core/Pages/Vault/ScanPage.xaml
Normal file
144
src/Core/Pages/Vault/ScanPage.xaml
Normal file
@@ -0,0 +1,144 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.ScanPage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:skia="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls"
|
||||
xmlns:core="clr-namespace:Bit.Core"
|
||||
xmlns:zxing="clr-namespace:ZXing.Net.Maui.Controls;assembly=ZXing.Net.MAUI.Controls"
|
||||
x:Name="_page"
|
||||
Title="{Binding ScanQrPageTitle}">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:ScanPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Cancel}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<Grid
|
||||
VerticalOptions="FillAndExpand"
|
||||
HorizontalOptions="FillAndExpand">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<!-- TODO: [MAUI-Migration]
|
||||
OnScanResult="OnScanResult"-->
|
||||
<zxing:CameraBarcodeReaderView
|
||||
x:Name="_zxing"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
VerticalOptions="FillAndExpand"
|
||||
AutomationId="zxingScannerView"
|
||||
IsVisible="{Binding ShowScanner}"
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="3"
|
||||
BarcodesDetected="_zxing_BarcodesDetected"/>
|
||||
<StackLayout
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
IsVisible="{Binding ShowScanner}"
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
Margin="30,0">
|
||||
|
||||
<skia:SKCanvasView
|
||||
x:Name="SkCanvasView"
|
||||
Margin="0,50,0,0"
|
||||
WidthRequest="250"
|
||||
HeightRequest="250"
|
||||
IsVisible="{Binding ShowScanner}"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="Center"
|
||||
PaintSurface="OnCanvasViewPaintSurface"/>
|
||||
|
||||
<controls:IconButton
|
||||
x:Name="_checkIcon"
|
||||
IsVisible="{Binding ShowScanner}"
|
||||
StyleClass="box-row-button, box-row-button-platform"
|
||||
Text="{Binding Source={x:Static core:BitwardenIcons.CheckCircle}}"
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="Start"
|
||||
FontSize="Title"
|
||||
TextColor="Transparent"/>
|
||||
</StackLayout>
|
||||
<BoxView
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
IsVisible="{Binding ShowScanner, Converter={StaticResource inverseBool}}"
|
||||
BackgroundColor="{DynamicResource BackgroundColor}"/>
|
||||
<StackLayout
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
IsVisible="{Binding ShowScanner, Converter={StaticResource inverseBool}}"
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
Margin="30,0">
|
||||
<Label
|
||||
Text="{u:I18n EnterKeyManually}"
|
||||
FontSize="Title" />
|
||||
<Label
|
||||
Text="{u:I18n AuthenticatorKeyScanner}"
|
||||
StyleClass="box-label" />
|
||||
<controls:MonoEntry
|
||||
x:Name="_authenticationKeyEntry"
|
||||
Text="{Binding TotpAuthenticationKey}"
|
||||
IsSpellCheckEnabled="False"
|
||||
IsTextPredictionEnabled="False"
|
||||
StyleClass="box-value" />
|
||||
<Button
|
||||
Text="{u:I18n AddTotp}"
|
||||
StyleClass="box-button-row"
|
||||
Clicked="AddAuthenticationKey_OnClicked"/>
|
||||
</StackLayout>
|
||||
<BoxView
|
||||
Grid.Column="0"
|
||||
Grid.Row="2"
|
||||
VerticalOptions="Fill"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
BackgroundColor="Black"
|
||||
Opacity="0.7" />
|
||||
<StackLayout
|
||||
VerticalOptions="Start"
|
||||
HorizontalOptions="Center"
|
||||
Grid.Column="0"
|
||||
Grid.Row="2">
|
||||
<Label
|
||||
Text="{Binding CameraInstructionTop}"
|
||||
AutomationId="zxingDefaultOverlay_TopTextLabel"
|
||||
Margin="30,15,30,0"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
StyleClass="text-sm"
|
||||
TextColor="White" />
|
||||
</StackLayout>
|
||||
<Label
|
||||
FormattedText="{Binding ToggleScanModeLabel}"
|
||||
Grid.Column="0"
|
||||
Grid.Row="2"
|
||||
Margin="0,15"
|
||||
StyleClass="text-sm"
|
||||
FontAttributes="Bold"
|
||||
VerticalOptions="End"
|
||||
HorizontalOptions="Center" >
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="ToggleScanMode_OnTapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
</Grid>
|
||||
</pages:BaseContentPage>
|
||||
308
src/Core/Pages/Vault/ScanPage.xaml.cs
Normal file
308
src/Core/Pages/Vault/ScanPage.xaml.cs
Normal file
@@ -0,0 +1,308 @@
|
||||
using System.Diagnostics;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Utilities;
|
||||
using SkiaSharp;
|
||||
using SkiaSharp.Views.Maui;
|
||||
using ZXing.Net.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class ScanPage : BaseContentPage
|
||||
{
|
||||
private ScanPageViewModel ViewModel => BindingContext as ScanPageViewModel;
|
||||
private readonly Action<string> _callback;
|
||||
private CancellationTokenSource _autofocusCts;
|
||||
private Task _continuousAutofocusTask;
|
||||
private readonly Color _greenColor;
|
||||
private readonly SKColor _blueSKColor;
|
||||
private readonly SKColor _greenSKColor;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _pageIsActive;
|
||||
private bool _qrcodeFound;
|
||||
private float _scale;
|
||||
private readonly LazyResolve<ILogger> _logger = new LazyResolve<ILogger>("logger");
|
||||
|
||||
public ScanPage(Action<string> callback)
|
||||
{
|
||||
InitializeComponent();
|
||||
_callback = callback;
|
||||
ViewModel.InitScannerCommand = new Command(() => InitScanner());
|
||||
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.RemoveAt(0);
|
||||
}
|
||||
|
||||
_greenColor = ThemeManager.GetResourceColor("SuccessColor");
|
||||
_greenSKColor = _greenColor.ToSKColor();
|
||||
_blueSKColor = ThemeManager.GetResourceColor("PrimaryColor").ToSKColor();
|
||||
_stopwatch = new Stopwatch();
|
||||
_qrcodeFound = false;
|
||||
}
|
||||
|
||||
protected override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
StartScanner();
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
StopScanner().FireAndForget();
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
// Fix known bug with DelayBetweenAnalyzingFrames & DelayBetweenContinuousScans: https://github.com/Redth/ZXing.Net.Mobile/issues/721
|
||||
private void InitScanner()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ViewModel.HasCameraPermission || !ViewModel.ShowScanner || _zxing != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//_zxing = new ZXingScannerView();
|
||||
_zxing.Options = new BarcodeReaderOptions
|
||||
{
|
||||
//UseNativeScanning = true,
|
||||
//PossibleFormats = new List<ZXing.BarcodeFormat> { ZXing.BarcodeFormat.QR_CODE },
|
||||
Formats = BarcodeFormat.QrCode,
|
||||
AutoRotate = false,
|
||||
TryInverted = true,
|
||||
//DelayBetweenAnalyzingFrames = 5,
|
||||
//DelayBetweenContinuousScans = 5
|
||||
};
|
||||
//_scannerContainer.Content = _zxing;
|
||||
StartScanner();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Value.Exception(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartScanner()
|
||||
{
|
||||
if (_zxing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//_zxing.OnScanResult -= OnScanResult;
|
||||
//_zxing.OnScanResult += OnScanResult;
|
||||
// TODO: [MAUI-Migration] [Critical]
|
||||
//_zxing.IsScanning = true;
|
||||
|
||||
// Fix for Autofocus, now it's done every 2 seconds so that the user does't have to do it
|
||||
// https://github.com/Redth/ZXing.Net.Mobile/issues/414
|
||||
_autofocusCts?.Cancel();
|
||||
_autofocusCts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
|
||||
|
||||
var autofocusCts = _autofocusCts;
|
||||
// this task is needed to be awaited OnDisappearing to avoid some crashes
|
||||
// when changing the value of _zxing.IsScanning
|
||||
_continuousAutofocusTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!autofocusCts.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), autofocusCts.Token);
|
||||
await Device.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
if (!autofocusCts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_zxing.AutoFocus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Value.Exception(ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Value.Exception(ex);
|
||||
}
|
||||
}, autofocusCts.Token);
|
||||
_pageIsActive = true;
|
||||
AnimationLoopAsync();
|
||||
}
|
||||
|
||||
private async Task StopScanner()
|
||||
{
|
||||
if (_zxing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_autofocusCts?.Cancel();
|
||||
if (_continuousAutofocusTask != null)
|
||||
{
|
||||
await _continuousAutofocusTask;
|
||||
}
|
||||
// TODO: [MAUI-Migration] [Critical]
|
||||
//_zxing.IsScanning = false;
|
||||
//_zxing.OnScanResult -= OnScanResult;
|
||||
_pageIsActive = false;
|
||||
}
|
||||
|
||||
// TODO: [MAUI-Migration] [Critical]
|
||||
private async void _zxing_BarcodesDetected(System.Object sender, ZXing.Net.Maui.BarcodeDetectionEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!e.Results.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var result = e.Results[0];
|
||||
// Stop analysis until we navigate away so we don't keep reading barcodes
|
||||
// TODO: [MAUI-Migration] [Critical]
|
||||
//_zxing.IsAnalyzing = false;
|
||||
var text = result?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
if (text.StartsWith("otpauth://totp"))
|
||||
{
|
||||
await QrCodeFoundAsync();
|
||||
_callback(text);
|
||||
return;
|
||||
}
|
||||
else if (Uri.TryCreate(text, UriKind.Absolute, out Uri uri) &&
|
||||
!string.IsNullOrWhiteSpace(uri?.Query))
|
||||
{
|
||||
var queryParts = uri.Query.Substring(1).ToLowerInvariant().Split('&');
|
||||
foreach (var part in queryParts)
|
||||
{
|
||||
if (part.StartsWith("secret="))
|
||||
{
|
||||
await QrCodeFoundAsync();
|
||||
var subResult = part.Substring(7);
|
||||
if (!string.IsNullOrEmpty(subResult))
|
||||
{
|
||||
_callback(subResult.ToUpperInvariant());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_callback(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Value?.Exception(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task QrCodeFoundAsync()
|
||||
{
|
||||
_qrcodeFound = true;
|
||||
Vibration.Vibrate();
|
||||
await Task.Delay(1000);
|
||||
// TODO: [MAUI-Migration] [Critical]
|
||||
//_zxing.IsScanning = false;
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAuthenticationKey_OnClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ViewModel.TotpAuthenticationKey))
|
||||
{
|
||||
_callback(ViewModel.TotpAuthenticationKey);
|
||||
return;
|
||||
}
|
||||
_callback(null);
|
||||
}
|
||||
|
||||
private void ToggleScanMode_OnTapped(object sender, EventArgs e)
|
||||
{
|
||||
ViewModel.ToggleScanModeCommand.Execute(null);
|
||||
if (!ViewModel.ShowScanner)
|
||||
{
|
||||
_authenticationKeyEntry.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
|
||||
{
|
||||
var info = args.Info;
|
||||
var surface = args.Surface;
|
||||
var canvas = surface.Canvas;
|
||||
var margins = 20;
|
||||
var maxSquareSize = (Math.Min(info.Height, info.Width) * 0.9f - margins) * _scale;
|
||||
var squareSize = maxSquareSize;
|
||||
var lineSize = squareSize * 0.15f;
|
||||
var startXPoint = (info.Width / 2) - (squareSize / 2);
|
||||
var startYPoint = (info.Height / 2) - (squareSize / 2);
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
using (var strokePaint = new SKPaint
|
||||
{
|
||||
Color = _qrcodeFound ? _greenSKColor : _blueSKColor,
|
||||
StrokeWidth = 9 * _scale,
|
||||
StrokeCap = SKStrokeCap.Round,
|
||||
})
|
||||
{
|
||||
canvas.Scale(1, 1);
|
||||
//top left
|
||||
canvas.DrawLine(startXPoint, startYPoint, startXPoint, startYPoint + lineSize, strokePaint);
|
||||
canvas.DrawLine(startXPoint, startYPoint, startXPoint + lineSize, startYPoint, strokePaint);
|
||||
//bot left
|
||||
canvas.DrawLine(startXPoint, startYPoint + squareSize, startXPoint, startYPoint + squareSize - lineSize, strokePaint);
|
||||
canvas.DrawLine(startXPoint, startYPoint + squareSize, startXPoint + lineSize, startYPoint + squareSize, strokePaint);
|
||||
//top right
|
||||
canvas.DrawLine(startXPoint + squareSize, startYPoint, startXPoint + squareSize - lineSize, startYPoint, strokePaint);
|
||||
canvas.DrawLine(startXPoint + squareSize, startYPoint, startXPoint + squareSize, startYPoint + lineSize, strokePaint);
|
||||
//bot right
|
||||
canvas.DrawLine(startXPoint + squareSize, startYPoint + squareSize, startXPoint + squareSize - lineSize, startYPoint + squareSize, strokePaint);
|
||||
canvas.DrawLine(startXPoint + squareSize, startYPoint + squareSize, startXPoint + squareSize, startYPoint + squareSize - lineSize, strokePaint);
|
||||
}
|
||||
}
|
||||
|
||||
async Task AnimationLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_stopwatch.Start();
|
||||
while (_pageIsActive)
|
||||
{
|
||||
var t = _stopwatch.Elapsed.TotalSeconds % 2 / 2;
|
||||
_scale = (20 - (1 - (float)Math.Sin(4 * Math.PI * t))) / 20;
|
||||
SkCanvasView.InvalidateSurface();
|
||||
await Task.Delay(TimeSpan.FromSeconds(1.0 / 30));
|
||||
if (_qrcodeFound && _scale > 0.98f)
|
||||
{
|
||||
_checkIcon.TextColor = _greenColor;
|
||||
SkCanvasView.InvalidateSurface();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Value?.Exception(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stopwatch?.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/Core/Pages/Vault/ScanPageViewModel.cs
Normal file
130
src/Core/Pages/Vault/ScanPageViewModel.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class ScanPageViewModel : BaseViewModel
|
||||
{
|
||||
private bool _showScanner = true;
|
||||
private string _totpAuthenticationKey;
|
||||
private IPlatformUtilsService _platformUtilsService;
|
||||
private IDeviceActionService _deviceActionService;
|
||||
private ILogger _logger;
|
||||
|
||||
public ScanPageViewModel()
|
||||
{
|
||||
ToggleScanModeCommand = new AsyncCommand(ToggleScanMode, onException: HandleException);
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>();
|
||||
InitAsync().FireAndForget();
|
||||
}
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Device.InvokeOnMainThreadAsync(async () =>
|
||||
{
|
||||
var hasCameraPermission = await PermissionManager.CheckAndRequestPermissionAsync(new Permissions.Camera());
|
||||
HasCameraPermission = hasCameraPermission == PermissionStatus.Granted;
|
||||
ShowScanner = hasCameraPermission == PermissionStatus.Granted;
|
||||
});
|
||||
|
||||
if (!HasCameraPermission)
|
||||
{
|
||||
return;
|
||||
}
|
||||
InitScannerCommand.Execute(null);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
HandleException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand ToggleScanModeCommand { get; set; }
|
||||
public ICommand InitScannerCommand { get; set; }
|
||||
|
||||
public bool HasCameraPermission { get; set; }
|
||||
public string ScanQrPageTitle => ShowScanner ? AppResources.ScanQrTitle : AppResources.AuthenticatorKeyScanner;
|
||||
public string CameraInstructionTop => ShowScanner ? AppResources.PointYourCameraAtTheQRCode : AppResources.OnceTheKeyIsSuccessfullyEntered;
|
||||
public string TotpAuthenticationKey
|
||||
{
|
||||
get => _totpAuthenticationKey;
|
||||
set => SetProperty(ref _totpAuthenticationKey, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ToggleScanModeLabel)
|
||||
});
|
||||
}
|
||||
public bool ShowScanner
|
||||
{
|
||||
get => _showScanner;
|
||||
set => SetProperty(ref _showScanner, value,
|
||||
additionalPropertyNames: new string[]
|
||||
{
|
||||
nameof(ToggleScanModeLabel),
|
||||
nameof(ScanQrPageTitle),
|
||||
nameof(CameraInstructionTop)
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ToggleScanMode()
|
||||
{
|
||||
var cameraPermission = await PermissionManager.CheckAndRequestPermissionAsync(new Permissions.Camera());
|
||||
HasCameraPermission = cameraPermission == PermissionStatus.Granted;
|
||||
if (!HasCameraPermission)
|
||||
{
|
||||
var openAppSettingsResult = await _platformUtilsService.ShowDialogAsync(AppResources.EnableCamerPermissionToUseTheScanner, title: string.Empty, confirmText: AppResources.Settings, cancelText: AppResources.NoThanks);
|
||||
if (openAppSettingsResult)
|
||||
{
|
||||
_deviceActionService.OpenAppSettings();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ShowScanner = !ShowScanner;
|
||||
InitScannerCommand.Execute(null);
|
||||
}
|
||||
|
||||
public FormattedString ToggleScanModeLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
var fs = new FormattedString();
|
||||
fs.Spans.Add(new Span
|
||||
{
|
||||
Text = ShowScanner ? AppResources.CannotScanQRCode : AppResources.CannotAddAuthenticatorKey,
|
||||
TextColor = ThemeManager.GetResourceColor("TitleTextColor")
|
||||
});
|
||||
fs.Spans.Add(new Span
|
||||
{
|
||||
Text = ShowScanner ? AppResources.EnterKeyManually : AppResources.ScanQRCode,
|
||||
TextColor = ThemeManager.GetResourceColor("ScanningToggleModeTextColor")
|
||||
});
|
||||
return fs;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleException(Exception ex)
|
||||
{
|
||||
Microsoft.Maui.ApplicationModel.MainThread.InvokeOnMainThreadAsync(async () =>
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage);
|
||||
}).FireAndForget();
|
||||
_logger.Exception(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Core/Pages/Vault/SharePage.xaml
Normal file
85
src/Core/Pages/Vault/SharePage.xaml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="Bit.App.Pages.SharePage"
|
||||
xmlns:pages="clr-namespace:Bit.App.Pages"
|
||||
xmlns:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
x:DataType="pages:SharePageViewModel"
|
||||
x:Name="_page"
|
||||
Title="{Binding PageTitle}">
|
||||
<ContentPage.BindingContext>
|
||||
<pages:SharePageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="{u:I18n Cancel}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
|
||||
<ToolbarItem Text="{u:I18n Move}" Command="{Binding MoveCommand}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<u:InverseBoolConverter x:Key="inverseBool" />
|
||||
<u:IsNotNullConverter x:Key="notNull" />
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
|
||||
<ScrollView x:Name="_scrollView">
|
||||
<StackLayout Spacing="20">
|
||||
<StackLayout StyleClass="box"
|
||||
IsVisible="{Binding HasOrganizations, Converter={StaticResource inverseBool}}">
|
||||
<StackLayout StyleClass="box-row" Padding="10, 20">
|
||||
<Label Text="{u:I18n NoOrgsToList}" HorizontalTextAlignment="Center" />
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box"
|
||||
IsVisible="{Binding HasOrganizations}">
|
||||
<StackLayout StyleClass="box-row, box-row-input, box-row-input-options-platform">
|
||||
<Label
|
||||
Text="{u:I18n Organization}"
|
||||
StyleClass="box-label" />
|
||||
<Picker
|
||||
x:Name="_organizationPicker"
|
||||
ItemsSource="{Binding OrganizationOptions, Mode=OneTime}"
|
||||
SelectedIndex="{Binding OrganizationSelectedIndex}"
|
||||
StyleClass="box-value" />
|
||||
</StackLayout>
|
||||
<Label
|
||||
Text="{u:I18n MoveToOrgDesc}"
|
||||
StyleClass="box-footer-label" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box"
|
||||
IsVisible="{Binding OrganizationId, Converter={StaticResource notNull}}">
|
||||
<StackLayout StyleClass="box-row-header">
|
||||
<Label Text="{u:I18n Collections, Header=True}"
|
||||
StyleClass="box-header, box-header-platform" />
|
||||
</StackLayout>
|
||||
<StackLayout StyleClass="box-row"
|
||||
IsVisible="{Binding HasCollections, Converter={StaticResource inverseBool}}">
|
||||
<Label Text="{u:I18n NoCollectionsToList}" />
|
||||
</StackLayout>
|
||||
<controls:RepeaterView ItemsSource="{Binding Collections}" IsVisible="{Binding HasCollections}">
|
||||
<controls:RepeaterView.ItemTemplate>
|
||||
<DataTemplate x:DataType="pages:CollectionViewModel">
|
||||
<StackLayout Spacing="0" Padding="0">
|
||||
<StackLayout StyleClass="box-row, box-row-switch">
|
||||
<Label
|
||||
Text="{Binding Collection.Name}"
|
||||
StyleClass="box-label-regular"
|
||||
HorizontalOptions="StartAndExpand" />
|
||||
<Switch
|
||||
IsToggled="{Binding Checked}"
|
||||
StyleClass="box-value"
|
||||
HorizontalOptions="End" />
|
||||
</StackLayout>
|
||||
<BoxView StyleClass="box-row-separator" />
|
||||
</StackLayout>
|
||||
</DataTemplate>
|
||||
</controls:RepeaterView.ItemTemplate>
|
||||
</controls:RepeaterView>
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</pages:BaseContentPage>
|
||||
45
src/Core/Pages/Vault/SharePage.xaml.cs
Normal file
45
src/Core/Pages/Vault/SharePage.xaml.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Maui.Controls.PlatformConfiguration;
|
||||
using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class SharePage : BaseContentPage
|
||||
{
|
||||
private SharePageViewModel _vm;
|
||||
|
||||
public SharePage(string cipherId)
|
||||
{
|
||||
InitializeComponent();
|
||||
_vm = BindingContext as SharePageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.CipherId = cipherId;
|
||||
SetActivityIndicator();
|
||||
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
|
||||
if (Device.RuntimePlatform == Device.Android)
|
||||
{
|
||||
ToolbarItems.RemoveAt(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
_organizationPicker.On<iOS>().SetUpdateMode(UpdateMode.WhenFinished);
|
||||
}
|
||||
_organizationPicker.ItemDisplayBinding = new Binding("Key");
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await LoadOnAppearedAsync(_scrollView, true, () => _vm.LoadAsync());
|
||||
}
|
||||
|
||||
private async void Close_Clicked(object sender, System.EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
await Navigation.PopModalAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/Core/Pages/Vault/SharePageViewModel.cs
Normal file
175
src/Core/Pages/Vault/SharePageViewModel.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.App.Utilities;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class SharePageViewModel : BaseViewModel
|
||||
{
|
||||
private readonly IDeviceActionService _deviceActionService;
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
private CipherView _cipher;
|
||||
private int _organizationSelectedIndex;
|
||||
private bool _hasCollections;
|
||||
private bool _hasOrganizations;
|
||||
private List<Core.Models.View.CollectionView> _writeableCollections;
|
||||
|
||||
public SharePageViewModel()
|
||||
{
|
||||
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
|
||||
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
|
||||
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
|
||||
_collectionService = ServiceContainer.Resolve<ICollectionService>("collectionService");
|
||||
Collections = new ExtendedObservableCollection<CollectionViewModel>();
|
||||
OrganizationOptions = new List<KeyValuePair<string, string>>();
|
||||
PageTitle = AppResources.MoveToOrganization;
|
||||
|
||||
MoveCommand = new AsyncCommand(MoveAsync, onException: ex => HandleException(ex), allowsMultipleExecutions: false);
|
||||
}
|
||||
|
||||
public string CipherId { get; set; }
|
||||
public string OrganizationId { get; set; }
|
||||
public List<KeyValuePair<string, string>> OrganizationOptions { get; set; }
|
||||
public ExtendedObservableCollection<CollectionViewModel> Collections { get; set; }
|
||||
public int OrganizationSelectedIndex
|
||||
{
|
||||
get => _organizationSelectedIndex;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _organizationSelectedIndex, value))
|
||||
{
|
||||
OrganizationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public bool HasCollections
|
||||
{
|
||||
get => _hasCollections;
|
||||
set => SetProperty(ref _hasCollections, value);
|
||||
}
|
||||
public bool HasOrganizations
|
||||
{
|
||||
get => _hasOrganizations;
|
||||
set => SetProperty(ref _hasOrganizations, value);
|
||||
}
|
||||
|
||||
public ICommand MoveCommand { get; }
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
var allCollections = await _collectionService.GetAllDecryptedAsync();
|
||||
_writeableCollections = allCollections.Where(c => !c.ReadOnly).ToList();
|
||||
|
||||
var orgs = await _organizationService.GetAllAsync();
|
||||
OrganizationOptions = orgs.OrderBy(o => o.Name)
|
||||
.Where(o => o.Enabled && o.Status == OrganizationUserStatusType.Confirmed)
|
||||
.Select(o => new KeyValuePair<string, string>(o.Name, o.Id)).ToList();
|
||||
HasOrganizations = OrganizationOptions.Any();
|
||||
|
||||
var cipherDomain = await _cipherService.GetAsync(CipherId);
|
||||
_cipher = await cipherDomain.DecryptAsync();
|
||||
if (OrganizationId == null && OrganizationOptions.Any())
|
||||
{
|
||||
OrganizationId = OrganizationOptions.First().Value;
|
||||
}
|
||||
OrganizationSelectedIndex = string.IsNullOrWhiteSpace(OrganizationId) ? 0 :
|
||||
OrganizationOptions.FindIndex(k => k.Value == OrganizationId);
|
||||
FilterCollections();
|
||||
}
|
||||
|
||||
public async Task<bool> MoveAsync()
|
||||
{
|
||||
var selectedCollectionIds = Collections?.Where(c => c.Checked).Select(c => c.Collection.Id);
|
||||
if (!selectedCollectionIds?.Any() ?? true)
|
||||
{
|
||||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.SelectOneCollection,
|
||||
AppResources.Ok);
|
||||
return false;
|
||||
}
|
||||
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage,
|
||||
AppResources.InternetConnectionRequiredTitle);
|
||||
return false;
|
||||
}
|
||||
|
||||
var cipherDomain = await _cipherService.GetAsync(CipherId);
|
||||
var cipherView = await cipherDomain.DecryptAsync();
|
||||
|
||||
var checkedCollectionIds = new HashSet<string>(selectedCollectionIds);
|
||||
try
|
||||
{
|
||||
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
|
||||
var error = await _cipherService.ShareWithServerAsync(cipherView, OrganizationId, checkedCollectionIds);
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
|
||||
if (error == ICipherService.ShareWithServerError.DuplicatedPasskeyInOrg)
|
||||
{
|
||||
_platformUtilsService.ShowToast(null, null, AppResources.ThisItemCannotBeSharedWithTheOrganizationBecauseThereIsOneAlreadyWithTheSamePasskey);
|
||||
return false;
|
||||
}
|
||||
|
||||
var movedItemToOrgText = string.Format(AppResources.MovedItemToOrg, cipherView.Name,
|
||||
(await _organizationService.GetAsync(OrganizationId)).Name);
|
||||
_platformUtilsService.ShowToast("success", null, movedItemToOrgText);
|
||||
await Page.Navigation.PopModalAsync();
|
||||
return true;
|
||||
}
|
||||
catch (ApiException e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e?.Error != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
if (e.Message != null)
|
||||
{
|
||||
await _platformUtilsService.ShowDialogAsync(e.Message, AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OrganizationChanged()
|
||||
{
|
||||
if (OrganizationSelectedIndex > -1)
|
||||
{
|
||||
OrganizationId = OrganizationOptions[OrganizationSelectedIndex].Value;
|
||||
FilterCollections();
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterCollections()
|
||||
{
|
||||
if (OrganizationId == null || !_writeableCollections.Any())
|
||||
{
|
||||
Collections.ResetWithRange(new List<CollectionViewModel>());
|
||||
}
|
||||
else
|
||||
{
|
||||
var cols = _writeableCollections.Where(c => c.OrganizationId == OrganizationId)
|
||||
.Select(c => new CollectionViewModel { Collection = c }).ToList();
|
||||
Collections.ResetWithRange(cols);
|
||||
}
|
||||
HasCollections = Collections.Any();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user