1
0
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:
Federico Maccaroni
2023-09-29 11:02:19 -03:00
parent bbef0f8c93
commit 8ef9443b1e
717 changed files with 5367 additions and 4702 deletions

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

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

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

View 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));
}
}
}

View 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);
}
}
}
}

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

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

View 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);
}
}
}

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

View 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);
}
}
}
}

View 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);
}
}
}

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

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

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

View 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="&#xe5c4;"
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>

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

View 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));
}
}
}

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

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

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

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

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

View File

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

View 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);
}
}

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

View File

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

View 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;
});
}
}
}

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

View File

@@ -0,0 +1,6 @@
namespace Bit.App.Pages
{
public interface IGroupingsPageListItem
{
}
}

View 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));
}
}
}

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

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

View 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);
}
}
}

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

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

View 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);
}
}
}

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

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

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