mirror of
https://github.com/bitwarden/mobile.git
synced 2024-11-15 10:25:20 +01:00
[PM-5154] Implement combined view for passwords and passkeys on iOS Autofill extension (#3075)
* PM-5154 Implemented combined view of passwords and passkeys and improved search and items UI * PM-5154 Code improvement from PR feedback * PM-5154 Code improvement to log unknown exceptions
This commit is contained in:
parent
53aedea93a
commit
144fc7c727
@ -5407,6 +5407,15 @@ namespace Bit.Core.Resources.Localization {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Passwords.
|
||||
/// </summary>
|
||||
public static string Passwords {
|
||||
get {
|
||||
return ResourceManager.GetString("Passwords", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This password was not found in any known data breaches. It should be safe to use..
|
||||
/// </summary>
|
||||
|
@ -2936,4 +2936,7 @@ Do you want to switch to this account?</value>
|
||||
<value>There was a problem reading your passkey for {0}. Try again later.</value>
|
||||
<comment>The parameter is the RpId</comment>
|
||||
</data>
|
||||
<data name="Passwords" xml:space="preserve">
|
||||
<value>Passwords</value>
|
||||
</data>
|
||||
</root>
|
||||
|
@ -109,8 +109,9 @@ namespace Bit.Core.Services
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
throw new UnknownError();
|
||||
}
|
||||
}
|
||||
@ -203,8 +204,9 @@ namespace Bit.Core.Services
|
||||
Signature = signature
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
throw new UnknownError();
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,24 @@ namespace Bit.iOS.Autofill
|
||||
private readonly LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
|
||||
private readonly LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>();
|
||||
|
||||
[Export("prepareCredentialListForServiceIdentifiers:requestParameters:")]
|
||||
public override void PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (UIDevice.CurrentDevice.CheckSystemVersion(17, 0) && !string.IsNullOrEmpty(requestParameters?.RelyingPartyIdentifier))
|
||||
{
|
||||
_context.PasskeyCredentialRequestParameters = requestParameters;
|
||||
}
|
||||
|
||||
PrepareCredentialList(serviceIdentifiers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnProvidingCredentialException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public override async void PrepareInterfaceForPasskeyRegistration(IASCredentialRequest registrationRequest)
|
||||
{
|
||||
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
|
||||
@ -239,7 +257,7 @@ namespace Bit.iOS.Autofill
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference)
|
||||
internal async Task<bool> VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -88,57 +88,14 @@ namespace Bit.iOS.Autofill
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0)
|
||||
{
|
||||
PerformSegue(SegueConstants.LOGIN_SEARCH, this);
|
||||
}
|
||||
else
|
||||
if (_context.IsCreatingOrPreparingListForPasskey || _context.ServiceIdentifiers?.Length > 0)
|
||||
{
|
||||
PerformSegue(SegueConstants.LOGIN_LIST, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnProvidingCredentialException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[Export("prepareCredentialListForServiceIdentifiers:requestParameters:")]
|
||||
public override async void PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
InitAppIfNeeded();
|
||||
_context.VaultUnlockedDuringThisSession = false;
|
||||
_context.ServiceIdentifiers = serviceIdentifiers;
|
||||
if (serviceIdentifiers.Length > 0)
|
||||
{
|
||||
var uri = serviceIdentifiers[0].Identifier;
|
||||
if (serviceIdentifiers[0].Type == ASCredentialServiceIdentifierType.Domain)
|
||||
{
|
||||
uri = string.Concat("https://", uri);
|
||||
}
|
||||
_context.UrlString = uri;
|
||||
}
|
||||
if (!await IsAuthed())
|
||||
{
|
||||
await _accountsManager.NavigateOnAccountChangeAsync(false);
|
||||
}
|
||||
else if (await IsLocked())
|
||||
{
|
||||
PerformSegue(SegueConstants.LOCK, this);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0)
|
||||
{
|
||||
PerformSegue(SegueConstants.LOGIN_SEARCH, this);
|
||||
}
|
||||
else
|
||||
{
|
||||
PerformSegue(SegueConstants.LOGIN_LIST, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -306,6 +263,8 @@ namespace Bit.iOS.Autofill
|
||||
return;
|
||||
}
|
||||
|
||||
_context.PickCredentialForFido2GetAssertionFromListTcs?.TrySetCanceled();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(totp))
|
||||
{
|
||||
UIPasteboard.General.String = totp;
|
||||
@ -324,7 +283,7 @@ namespace Bit.iOS.Autofill
|
||||
});
|
||||
}
|
||||
|
||||
private void OnProvidingCredentialException(Exception ex)
|
||||
internal void OnProvidingCredentialException(Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
CancelRequest(ASExtensionErrorCode.Failed);
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.iOS.Autofill.Models;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.iOS.Autofill.Models;
|
||||
|
||||
namespace Bit.iOS.Autofill
|
||||
{
|
||||
@ -6,5 +7,8 @@ namespace Bit.iOS.Autofill
|
||||
{
|
||||
Context Context { get; }
|
||||
CredentialProviderViewController CPViewController { get; }
|
||||
void OnItemsLoaded(string searchFilter);
|
||||
Task ReloadItemsAsync();
|
||||
void ReloadTableViewData();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AuthenticationServices;
|
||||
using Bit.App.Controls;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -15,8 +16,8 @@ using Bit.iOS.Core.Controllers;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.iOS.Core.Views;
|
||||
using CoreFoundation;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using UIKit;
|
||||
|
||||
namespace Bit.iOS.Autofill
|
||||
@ -45,8 +46,30 @@ namespace Bit.iOS.Autofill
|
||||
LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
|
||||
LazyResolve<ILogger> _logger = new LazyResolve<ILogger>();
|
||||
LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
|
||||
LazyResolve<IFido2AuthenticatorService> _fido2AuthenticatorService = new LazyResolve<IFido2AuthenticatorService>();
|
||||
|
||||
bool _alreadyLoadItemsOnce = false;
|
||||
bool _isLoading;
|
||||
|
||||
private string NavTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
return AppResources.SavePasskey;
|
||||
}
|
||||
|
||||
if (Context.IsCreatingOrPreparingListForPasskey)
|
||||
{
|
||||
return AppResources.Autofill;
|
||||
}
|
||||
|
||||
return AppResources.Items;
|
||||
}
|
||||
}
|
||||
|
||||
private TableSource Source => (TableSource)TableView.Source;
|
||||
|
||||
public async override void ViewDidLoad()
|
||||
{
|
||||
@ -58,16 +81,22 @@ namespace Bit.iOS.Autofill
|
||||
|
||||
SubscribeSyncCompleted();
|
||||
|
||||
NavItem.Title = Context.IsCreatingPasskey ? AppResources.SavePasskey : AppResources.Items;
|
||||
NavItem.Title = NavTitle;
|
||||
|
||||
_cancelButton.Title = AppResources.Cancel;
|
||||
|
||||
_searchBar.Placeholder = AppResources.Search;
|
||||
_searchBar.BackgroundColor = _searchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor;
|
||||
_searchBar.UpdateThemeIfNeeded();
|
||||
_searchBar.Delegate = new ExtensionSearchDelegate(TableView);
|
||||
|
||||
TableView.BackgroundColor = ThemeHelpers.BackgroundColor;
|
||||
|
||||
var tableSource = new TableSource(this);
|
||||
TableView.Source = tableSource;
|
||||
tableSource.RegisterTableViewCells(TableView);
|
||||
|
||||
if (Context.IsCreatingPasskey)
|
||||
if (Context.IsCreatingOrPreparingListForPasskey)
|
||||
{
|
||||
TableView.SectionHeaderHeight = 55;
|
||||
TableView.RegisterClassForHeaderFooterViewReuse(typeof(HeaderItemView), HEADER_SECTION_IDENTIFIER);
|
||||
@ -78,8 +107,6 @@ namespace Bit.iOS.Autofill
|
||||
TableView.SectionHeaderTopPadding = 0;
|
||||
}
|
||||
|
||||
await ((TableSource)TableView.Source).LoadAsync();
|
||||
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
_emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, Context.UrlString);
|
||||
@ -91,8 +118,6 @@ namespace Bit.iOS.Autofill
|
||||
_emptyViewButton.ClipsToBounds = true;
|
||||
}
|
||||
|
||||
_alreadyLoadItemsOnce = true;
|
||||
|
||||
var storageService = ServiceContainer.Resolve<IStorageService>("storageService");
|
||||
var needsAutofillReplacement = await storageService.GetAsync<bool?>(
|
||||
Core.Constants.AutofillNeedsIdentityReplacementKey);
|
||||
@ -113,6 +138,22 @@ namespace Bit.iOS.Autofill
|
||||
}, false);
|
||||
|
||||
_accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView);
|
||||
|
||||
if (Context.IsPreparingListForPasskey)
|
||||
{
|
||||
var fido2UserInterface = new Fido2GetAssertionFromListUserInterface(Context,
|
||||
() => Task.CompletedTask,
|
||||
() => Context?.VaultUnlockedDuringThisSession ?? false,
|
||||
CPViewController.VerifyUserAsync,
|
||||
Source.ReloadWithAllowedFido2Credentials);
|
||||
|
||||
DoFido2GetAssertionAsync(fido2UserInterface).FireAndForget();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReloadItemsAsync();
|
||||
_alreadyLoadItemsOnce = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -120,6 +161,74 @@ namespace Bit.iOS.Autofill
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DoFido2GetAssertionAsync(IFido2GetAssertionUserInterface fido2GetAssertionUserInterface)
|
||||
{
|
||||
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
|
||||
{
|
||||
CPViewController.OnProvidingCredentialException(new InvalidOperationException("Trying to get assertion request before iOS 17"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Context.PasskeyCredentialRequestParameters is null)
|
||||
{
|
||||
CPViewController.OnProvidingCredentialException(new InvalidOperationException("Trying to get assertion request without a PasskeyCredentialRequestParameters"));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fido2AssertionResult = await _fido2AuthenticatorService.Value.GetAssertionAsync(new Fido2AuthenticatorGetAssertionParams
|
||||
{
|
||||
RpId = Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier,
|
||||
Hash = Context.PasskeyCredentialRequestParameters.ClientDataHash.ToArray(),
|
||||
UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.ToFido2UserVerificationPreference(Context.PasskeyCredentialRequestParameters.UserVerificationPreference),
|
||||
AllowCredentialDescriptorList = Context.PasskeyCredentialRequestParameters.AllowedCredentials?
|
||||
.Select(c => new PublicKeyCredentialDescriptor { Id = c.ToArray() })
|
||||
.ToArray()
|
||||
}, fido2GetAssertionUserInterface);
|
||||
|
||||
if (fido2AssertionResult.SelectedCredential is null)
|
||||
{
|
||||
throw new NullReferenceException("SelectedCredential must have a value");
|
||||
}
|
||||
|
||||
await CPViewController.CompleteAssertionRequest(new ASPasskeyAssertionCredential(
|
||||
NSData.FromArray(fido2AssertionResult.SelectedCredential.UserHandle),
|
||||
Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier,
|
||||
NSData.FromArray(fido2AssertionResult.Signature),
|
||||
Context.PasskeyCredentialRequestParameters.ClientDataHash,
|
||||
NSData.FromArray(fido2AssertionResult.AuthenticatorData),
|
||||
NSData.FromArray(fido2AssertionResult.SelectedCredential.Id)
|
||||
));
|
||||
}
|
||||
catch (InvalidOperationNeedsUIException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Context?.IsExecutingWithoutUserInteraction == false)
|
||||
{
|
||||
await _platformUtilsService.Value.ShowDialogAsync(
|
||||
string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier),
|
||||
AppResources.ErrorReadingPasskey);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelButton_TouchUpInside(object sender, EventArgs e)
|
||||
{
|
||||
Cancel();
|
||||
@ -141,8 +250,55 @@ namespace Bit.iOS.Autofill
|
||||
}
|
||||
|
||||
partial void SearchBarButton_Activated(UIBarButtonItem sender)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Context.IsCreatingOrPreparingListForPasskey)
|
||||
{
|
||||
PerformSegue(SegueConstants.LOGIN_SEARCH_FROM_LIST, this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isLoading)
|
||||
{
|
||||
// if it's loading we simplify this logic to just avoid toggling the search bar visibility
|
||||
// and reloading items while this is taking place.
|
||||
return;
|
||||
}
|
||||
|
||||
UIView.Animate(0.3f,
|
||||
() =>
|
||||
{
|
||||
_tableViewTopToSearchBarConstraint.Active = !_tableViewTopToSearchBarConstraint.Active;
|
||||
_searchBar.Hidden = !_searchBar.Hidden;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (_tableViewTopToSearchBarConstraint.Active)
|
||||
{
|
||||
_searchBar?.BecomeFirstResponder();
|
||||
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
_emptyView.Hidden = true;
|
||||
TableView.Hidden = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchBar.Text = string.Empty;
|
||||
_searchBar.Text = null;
|
||||
|
||||
_searchBar.ResignFirstResponder();
|
||||
|
||||
ReloadItemsAsync().FireAndForget();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
}
|
||||
|
||||
partial void EmptyButton_Activated(UIButton sender)
|
||||
@ -250,8 +406,7 @@ namespace Bit.iOS.Autofill
|
||||
{
|
||||
try
|
||||
{
|
||||
await ((TableSource)TableView.Source).LoadAsync();
|
||||
TableView.ReloadData();
|
||||
await ReloadItemsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -262,10 +417,18 @@ namespace Bit.iOS.Autofill
|
||||
});
|
||||
}
|
||||
|
||||
public void OnEmptyList()
|
||||
public void OnItemsLoaded(string searchFilter)
|
||||
{
|
||||
_emptyView.Hidden = false;
|
||||
TableView.Hidden = true;
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
_emptyView.Hidden = !Source.IsEmpty;
|
||||
TableView.Hidden = Source.IsEmpty;
|
||||
|
||||
if (Source.IsEmpty)
|
||||
{
|
||||
_emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, string.IsNullOrEmpty(searchFilter) ? Context.UrlString : searchFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void ViewDidUnload()
|
||||
@ -281,8 +444,7 @@ namespace Bit.iOS.Autofill
|
||||
{
|
||||
try
|
||||
{
|
||||
await ((TableSource)TableView.Source).LoadAsync();
|
||||
TableView.ReloadData();
|
||||
await ReloadItemsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -306,6 +468,53 @@ namespace Bit.iOS.Autofill
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private async Task LoadSourceAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
TableView.Hidden = true;
|
||||
_searchBar.Hidden = true;
|
||||
_loadingView.Hidden = false;
|
||||
});
|
||||
|
||||
await Source.LoadAsync(string.IsNullOrEmpty(_searchBar?.Text), _searchBar?.Text);
|
||||
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
_loadingView.Hidden = true;
|
||||
TableView.Hidden = Context.IsCreatingPasskey && Source.IsEmpty;
|
||||
_searchBar.Hidden = string.IsNullOrEmpty(_searchBar?.Text);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReloadItemsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadSourceAsync();
|
||||
|
||||
_alreadyLoadItemsOnce = true;
|
||||
|
||||
await MainThread.InvokeOnMainThreadAsync(TableView.ReloadData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred).FireAndForget();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReloadTableViewData() => TableView.ReloadData();
|
||||
|
||||
public class TableSource : BaseLoginListTableSource<LoginListViewController>
|
||||
{
|
||||
public TableSource(LoginListViewController controller)
|
||||
@ -314,54 +523,6 @@ namespace Bit.iOS.Autofill
|
||||
}
|
||||
|
||||
protected override string LoginAddSegue => SegueConstants.ADD_LOGIN;
|
||||
|
||||
public override async Task LoadAsync(bool urlFilter = true, string searchFilter = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await base.LoadAsync(urlFilter, searchFilter);
|
||||
|
||||
if (Context.IsCreatingPasskey && !Items.Any())
|
||||
{
|
||||
Controller?.OnEmptyList();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public override UIView GetViewForHeader(UITableView tableView, nint section)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Context.IsCreatingPasskey
|
||||
&&
|
||||
tableView.DequeueReusableHeaderFooterView(LoginListViewController.HEADER_SECTION_IDENTIFIER) is HeaderItemView headerItemView)
|
||||
{
|
||||
headerItemView.SetHeaderText(AppResources.ChooseALoginToSaveThisPasskeyTo);
|
||||
return headerItemView;
|
||||
}
|
||||
|
||||
return new UIView(CGRect.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
return new UIView();
|
||||
}
|
||||
}
|
||||
|
||||
public override nint RowsInSection(UITableView tableview, nint section)
|
||||
{
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
return Items?.Count() ?? 0;
|
||||
}
|
||||
|
||||
return base.RowsInSection(tableview, section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
24
src/iOS.Autofill/LoginListViewController.designer.cs
generated
24
src/iOS.Autofill/LoginListViewController.designer.cs
generated
@ -24,6 +24,15 @@ namespace Bit.iOS.Autofill
|
||||
[Outlet]
|
||||
UIKit.UILabel _emptyViewLabel { get; set; }
|
||||
|
||||
[Outlet]
|
||||
UIKit.UIActivityIndicatorView _loadingView { get; set; }
|
||||
|
||||
[Outlet]
|
||||
UIKit.UISearchBar _searchBar { get; set; }
|
||||
|
||||
[Outlet]
|
||||
UIKit.NSLayoutConstraint _tableViewTopToSearchBarConstraint { get; set; }
|
||||
|
||||
[Outlet]
|
||||
[GeneratedCode ("iOS Designer", "1.0")]
|
||||
UIKit.UIBarButtonItem AddBarButton { get; set; }
|
||||
@ -72,6 +81,16 @@ namespace Bit.iOS.Autofill
|
||||
_emptyViewLabel = null;
|
||||
}
|
||||
|
||||
if (_searchBar != null) {
|
||||
_searchBar.Dispose ();
|
||||
_searchBar = null;
|
||||
}
|
||||
|
||||
if (_tableViewTopToSearchBarConstraint != null) {
|
||||
_tableViewTopToSearchBarConstraint.Dispose ();
|
||||
_tableViewTopToSearchBarConstraint = null;
|
||||
}
|
||||
|
||||
if (AddBarButton != null) {
|
||||
AddBarButton.Dispose ();
|
||||
AddBarButton = null;
|
||||
@ -96,6 +115,11 @@ namespace Bit.iOS.Autofill
|
||||
TableView.Dispose ();
|
||||
TableView = null;
|
||||
}
|
||||
|
||||
if (_loadingView != null) {
|
||||
_loadingView.Dispose ();
|
||||
_loadingView = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Services;
|
||||
using Bit.iOS.Autofill.Models;
|
||||
using Bit.iOS.Autofill.Utilities;
|
||||
using Bit.iOS.Core.Controllers;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.iOS.Core.Views;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
using Bit.iOS.Core.Controllers;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.iOS.Core.Views;
|
||||
using Bit.iOS.Autofill.Utilities;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.iOS.Autofill
|
||||
{
|
||||
@ -25,8 +25,11 @@ namespace Bit.iOS.Autofill
|
||||
public bool FromList { get; set; }
|
||||
|
||||
public async override void ViewDidLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
base.ViewDidLoad();
|
||||
|
||||
NavItem.Title = AppResources.SearchVault;
|
||||
CancelBarButton.Title = AppResources.Cancel;
|
||||
SearchBar.Placeholder = AppResources.Search;
|
||||
@ -34,14 +37,24 @@ namespace Bit.iOS.Autofill
|
||||
SearchBar.UpdateThemeIfNeeded();
|
||||
|
||||
TableView.RowHeight = UITableView.AutomaticDimension;
|
||||
TableView.EstimatedRowHeight = 44;
|
||||
TableView.EstimatedRowHeight = 55;
|
||||
|
||||
var tableSource = new TableSource(this);
|
||||
TableView.Source = tableSource;
|
||||
tableSource.RegisterTableViewCells(TableView);
|
||||
|
||||
if (UIDevice.CurrentDevice.CheckSystemVersion(15, 0))
|
||||
{
|
||||
TableView.SectionHeaderTopPadding = 0;
|
||||
}
|
||||
|
||||
SearchBar.Delegate = new ExtensionSearchDelegate(TableView);
|
||||
await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text);
|
||||
await ReloadItemsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public override void ViewDidAppear(bool animated)
|
||||
@ -90,11 +103,20 @@ namespace Bit.iOS.Autofill
|
||||
{
|
||||
DismissViewController(true, async () =>
|
||||
{
|
||||
await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text);
|
||||
TableView.ReloadData();
|
||||
await ReloadItemsAsync();
|
||||
});
|
||||
}
|
||||
|
||||
public void OnItemsLoaded(string searchFilter) { }
|
||||
|
||||
public async Task ReloadItemsAsync()
|
||||
{
|
||||
await((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text);
|
||||
TableView.ReloadData();
|
||||
}
|
||||
|
||||
public void ReloadTableViewData() => TableView.ReloadData();
|
||||
|
||||
public class TableSource : BaseLoginListTableSource<LoginSearchViewController>
|
||||
{
|
||||
public TableSource(LoginSearchViewController controller)
|
||||
|
@ -132,6 +132,13 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="830"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<searchBar hidden="YES" contentMode="redraw" translatesAutoresizingMaskIntoConstraints="NO" id="FnG-4H-B7H">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="56"/>
|
||||
<textInputTraits key="textInputTraits"/>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="2304" id="9s5-am-0Sm"/>
|
||||
</connections>
|
||||
</searchBar>
|
||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wNm-Sy-bJv" userLabel="EmptyView">
|
||||
<rect key="frame" x="0.0" y="200" width="414" height="228"/>
|
||||
<subviews>
|
||||
@ -172,7 +179,10 @@
|
||||
<constraint firstItem="FDN-Dp-jl3" firstAttribute="top" secondItem="wNm-Sy-bJv" secondAttribute="top" id="kKX-UE-JzG"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<tableView opaque="NO" clipsSubviews="YES" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="44" sectionHeaderHeight="22" sectionFooterHeight="22" translatesAutoresizingMaskIntoConstraints="NO" id="2305">
|
||||
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" animating="YES" style="large" translatesAutoresizingMaskIntoConstraints="NO" id="1MY-io-Uh6">
|
||||
<rect key="frame" x="188.5" y="70" width="37" height="37"/>
|
||||
</activityIndicatorView>
|
||||
<tableView hidden="YES" opaque="NO" clipsSubviews="YES" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="onDrag" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="44" sectionHeaderHeight="22" sectionFooterHeight="22" translatesAutoresizingMaskIntoConstraints="NO" id="2305">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="781"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<prototypes>
|
||||
@ -219,14 +229,25 @@
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="trailing" secondItem="Tq0-Ep-tHr" secondAttribute="trailing" id="5BV-0y-vU1"/>
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="bottom" secondItem="2305" secondAttribute="bottom" id="6EB-rh-lLS"/>
|
||||
<constraint firstItem="wNm-Sy-bJv" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" constant="200" id="CWX-uT-sfH"/>
|
||||
<constraint firstItem="FnG-4H-B7H" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" id="DyG-zK-MDg"/>
|
||||
<constraint firstItem="1MY-io-Uh6" firstAttribute="centerX" secondItem="BQW-dG-XMM" secondAttribute="centerX" id="E6j-d5-Slg"/>
|
||||
<constraint firstItem="wNm-Sy-bJv" firstAttribute="leading" secondItem="BQW-dG-XMM" secondAttribute="leading" id="Ytw-kT-KUB"/>
|
||||
<constraint firstItem="Tq0-Ep-tHr" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" id="eT6-Bv-JaR"/>
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="trailing" secondItem="2305" secondAttribute="trailing" id="ofJ-fL-adF"/>
|
||||
<constraint firstItem="FnG-4H-B7H" firstAttribute="leading" secondItem="BQW-dG-XMM" secondAttribute="leading" id="oz8-5S-g3r"/>
|
||||
<constraint firstItem="1MY-io-Uh6" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" constant="70" id="p2k-Pz-j9M"/>
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="bottom" secondItem="Tq0-Ep-tHr" secondAttribute="bottom" id="pBa-o1-Mtx"/>
|
||||
<constraint firstItem="2305" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" id="pGe-1e-B4s"/>
|
||||
<constraint firstItem="2305" firstAttribute="top" secondItem="FnG-4H-B7H" secondAttribute="bottom" priority="701" id="pGe-1e-B4s"/>
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="trailing" secondItem="FnG-4H-B7H" secondAttribute="trailing" id="qJE-bG-L66"/>
|
||||
<constraint firstItem="BQW-dG-XMM" firstAttribute="trailing" secondItem="wNm-Sy-bJv" secondAttribute="trailing" id="v0x-aS-ymc"/>
|
||||
<constraint firstItem="2305" firstAttribute="top" secondItem="BQW-dG-XMM" secondAttribute="top" priority="700" id="wML-RE-8T6"/>
|
||||
<constraint firstItem="2305" firstAttribute="leading" secondItem="BQW-dG-XMM" secondAttribute="leading" id="xfQ-VQ-yWe"/>
|
||||
</constraints>
|
||||
<variation key="default">
|
||||
<mask key="constraints">
|
||||
<exclude reference="pGe-1e-B4s"/>
|
||||
</mask>
|
||||
</variation>
|
||||
</view>
|
||||
<toolbarItems/>
|
||||
<navigationItem key="navigationItem" title="Logins" id="3734">
|
||||
@ -256,6 +277,9 @@
|
||||
<outlet property="_emptyViewButton" destination="Gv5-Xt-G9l" id="JHd-sV-VJC"/>
|
||||
<outlet property="_emptyViewImage" destination="FDN-Dp-jl3" id="Dzb-p3-tv0"/>
|
||||
<outlet property="_emptyViewLabel" destination="tEp-qe-xvE" id="CPZ-it-kVY"/>
|
||||
<outlet property="_loadingView" destination="1MY-io-Uh6" id="hAN-TF-dIn"/>
|
||||
<outlet property="_searchBar" destination="FnG-4H-B7H" id="UJI-d9-IUd"/>
|
||||
<outlet property="_tableViewTopToSearchBarConstraint" destination="pGe-1e-B4s" id="P32-0j-8YX"/>
|
||||
<segue destination="1845" kind="presentation" identifier="loginAddSegue" modalPresentationStyle="fullScreen" modalTransitionStyle="coverVertical" id="3731"/>
|
||||
<segue destination="11552" kind="show" identifier="loginSearchFromListSegue" id="12574"/>
|
||||
</connections>
|
||||
@ -619,7 +643,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<inferredMetricsTieBreakers>
|
||||
<segue reference="12959"/>
|
||||
<segue reference="12574"/>
|
||||
<segue reference="3731"/>
|
||||
</inferredMetricsTieBreakers>
|
||||
<resources>
|
||||
|
@ -14,6 +14,7 @@ namespace Bit.iOS.Autofill.Models
|
||||
public ASCredentialServiceIdentifier[] ServiceIdentifiers { get; set; }
|
||||
public ASPasswordCredentialIdentity PasswordCredentialIdentity { get; set; }
|
||||
public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; }
|
||||
public ASPasskeyCredentialRequestParameters PasskeyCredentialRequestParameters { get; set; }
|
||||
public bool Configuring { get; set; }
|
||||
public bool IsExecutingWithoutUserInteraction { get; set; }
|
||||
|
||||
@ -29,6 +30,12 @@ namespace Bit.iOS.Autofill.Models
|
||||
/// Param: isUserVerified if the user was verified. If null then the verification hasn't been done.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<(string cipherId, bool? isUserVerified)> PickCredentialForFido2CreationTcs { get; set; }
|
||||
/// <summary>
|
||||
/// This is used to defer the completion until a vault item is chosen to use the passkey.
|
||||
/// Param: cipher ID to add the passkey to.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<string> PickCredentialForFido2GetAssertionFromListTcs { get; set; }
|
||||
|
||||
public bool VaultUnlockedDuringThisSession { get; set; }
|
||||
|
||||
public ASPasskeyCredentialIdentity PasskeyCredentialIdentity
|
||||
@ -62,5 +69,9 @@ namespace Bit.iOS.Autofill.Models
|
||||
}
|
||||
|
||||
public bool IsPasskey => PasskeyCredentialRequest != null;
|
||||
|
||||
public bool IsPreparingListForPasskey => PasskeyCredentialRequestParameters != null;
|
||||
|
||||
public bool IsCreatingOrPreparingListForPasskey => IsCreatingPasskey || IsPreparingListForPasskey;
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ using Bit.App.Abstractions;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.iOS.Core.Models;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.iOS.Core.Views;
|
||||
using Foundation;
|
||||
@ -13,26 +14,11 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
{
|
||||
public static class AutofillHelpers
|
||||
{
|
||||
public async static Task TableRowSelectedAsync(UITableView tableView, NSIndexPath indexPath,
|
||||
ExtensionTableSource tableSource, CredentialProviderViewController cpViewController,
|
||||
UIViewController controller, IPasswordRepromptService passwordRepromptService,
|
||||
string loginAddSegue)
|
||||
public async static Task TableRowSelectedAsync(CipherViewModel item, ExtensionTableSource tableSource,
|
||||
CredentialProviderViewController cpViewController,
|
||||
UIViewController controller,
|
||||
IPasswordRepromptService passwordRepromptService)
|
||||
{
|
||||
tableView.DeselectRow(indexPath, true);
|
||||
tableView.EndEditing(true);
|
||||
|
||||
if (tableSource.Items == null || tableSource.Items.Count() == 0)
|
||||
{
|
||||
controller.PerformSegue(loginAddSegue, tableSource);
|
||||
return;
|
||||
}
|
||||
var item = tableSource.Items.ElementAt(indexPath.Row);
|
||||
if (item == null)
|
||||
{
|
||||
cpViewController.CompleteRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(item.Reprompt))
|
||||
{
|
||||
return;
|
||||
|
@ -1,13 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.iOS.Autofill.ListItems;
|
||||
using Bit.iOS.Autofill.Models;
|
||||
using Bit.iOS.Core.Controllers;
|
||||
using Bit.iOS.Core.Models;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.iOS.Core.Views;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
|
||||
@ -16,9 +24,13 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
public abstract class BaseLoginListTableSource<T> : ExtensionTableSource
|
||||
where T : UIViewController, ILoginListViewController
|
||||
{
|
||||
private IPasswordRepromptService _passwordRepromptService;
|
||||
private const string NoDataCellIdentifier = "NoDataCellIdentifier";
|
||||
|
||||
private readonly IPasswordRepromptService _passwordRepromptService;
|
||||
private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
|
||||
|
||||
List<string> _allowedFido2CipherIds = null;
|
||||
|
||||
public BaseLoginListTableSource(T controller)
|
||||
: base(controller.Context, controller)
|
||||
{
|
||||
@ -31,6 +43,209 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
|
||||
protected abstract string LoginAddSegue { get; }
|
||||
|
||||
public bool IsEmpty => Items?.Any() != true;
|
||||
|
||||
public override void RegisterTableViewCells(UITableView tableView)
|
||||
{
|
||||
base.RegisterTableViewCells(tableView);
|
||||
|
||||
tableView.RegisterClassForCellReuse(typeof(ExtendedUITableViewCell), NoDataCellIdentifier);
|
||||
}
|
||||
|
||||
protected override void OnItemsLoaded(string searchFilter, CancellationToken ct)
|
||||
{
|
||||
base.OnItemsLoaded(searchFilter, ct);
|
||||
|
||||
if (Context.IsPreparingListForPasskey && _allowedFido2CipherIds != null)
|
||||
{
|
||||
LoadFido2Ciphers(ct);
|
||||
}
|
||||
|
||||
Controller.OnItemsLoaded(searchFilter);
|
||||
}
|
||||
|
||||
private void LoadFido2Ciphers(CancellationToken ct)
|
||||
{
|
||||
var fido2CiphersToInsert = new List<CipherViewModel>();
|
||||
foreach (var item in Items.Where(i => i?.CipherView?.HasFido2Credential == true))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_allowedFido2CipherIds.Any()
|
||||
||
|
||||
_allowedFido2CipherIds.Contains(item.Id))
|
||||
{
|
||||
fido2CiphersToInsert.Add(item.ToPasskeyListItemCipherViewModel());
|
||||
}
|
||||
}
|
||||
|
||||
if (!fido2CiphersToInsert.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fido2CiphersToInsert.Reverse();
|
||||
|
||||
foreach (var item in fido2CiphersToInsert)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
Items.Insert(0, item);
|
||||
}
|
||||
}
|
||||
|
||||
protected override CipherViewModel CreateCipherViewModel(CipherView cipherView)
|
||||
{
|
||||
var vm = base.CreateCipherViewModel(cipherView);
|
||||
vm.ForceSectionIcon = Context.IsPreparingListForPasskey;
|
||||
return vm;
|
||||
}
|
||||
|
||||
protected override bool ShouldUseMainIconAsPasskey(CipherViewModel item, NSIndexPath indexPath)
|
||||
{
|
||||
if (!item.HasFido2Credential)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsPasskeySection(indexPath.Section) || !item.ForceSectionIcon;
|
||||
}
|
||||
|
||||
public override UIView GetViewForHeader(UITableView tableView, nint section)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Context.IsCreatingOrPreparingListForPasskey
|
||||
&&
|
||||
tableView.DequeueReusableHeaderFooterView(LoginListViewController.HEADER_SECTION_IDENTIFIER) is HeaderItemView headerItemView)
|
||||
{
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
headerItemView.SetHeaderText(AppResources.ChooseALoginToSaveThisPasskeyTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
headerItemView.SetHeaderText(IsPasskeySection(section) ? AppResources.Passkeys : AppResources.Passwords);
|
||||
}
|
||||
return headerItemView;
|
||||
}
|
||||
|
||||
return new UIView(CGRect.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
return new UIView();
|
||||
}
|
||||
}
|
||||
|
||||
public override nint NumberOfSections(UITableView tableView)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Context.IsPreparingListForPasskey)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public override nint RowsInSection(UITableView tableview, nint section)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Context.IsCreatingPasskey)
|
||||
{
|
||||
return Items?.Count() ?? 0;
|
||||
}
|
||||
|
||||
if (Context.IsPreparingListForPasskey)
|
||||
{
|
||||
var isPasskeySection = IsPasskeySection(section);
|
||||
var count = GetNumberOfItems(isPasskeySection);
|
||||
|
||||
return count == 0 ? 1 : count;
|
||||
}
|
||||
|
||||
return base.RowsInSection(tableview, section);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetNumberOfItems(bool forFido2)
|
||||
{
|
||||
if (!Context.IsPreparingListForPasskey)
|
||||
{
|
||||
return Items?.Count() ?? 0;
|
||||
}
|
||||
|
||||
return Items?.Count(i => i.IsFido2ListItem == forFido2) ?? 0;
|
||||
}
|
||||
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (GetNumberOfItems(IsPasskeySection(indexPath.Section)) == 0)
|
||||
{
|
||||
var noDataCell = tableView.DequeueReusableCell(NoDataCellIdentifier);
|
||||
|
||||
var text = AppResources.NoItemsToList;
|
||||
if (UIDevice.CurrentDevice.CheckSystemVersion(14, 0))
|
||||
{
|
||||
var config = noDataCell.DefaultContentConfiguration;
|
||||
config.Text = text;
|
||||
config.TextProperties.Color = ThemeHelpers.TextColor;
|
||||
config.TextProperties.Alignment = UIListContentTextAlignment.Center;
|
||||
config.TextProperties.LineBreakMode = UILineBreakMode.WordWrap;
|
||||
config.TextProperties.NumberOfLines = 0;
|
||||
noDataCell.ContentConfiguration = config;
|
||||
}
|
||||
else
|
||||
{
|
||||
noDataCell.TextLabel.Text = text;
|
||||
noDataCell.TextLabel.TextAlignment = UITextAlignment.Center;
|
||||
noDataCell.TextLabel.LineBreakMode = UILineBreakMode.WordWrap;
|
||||
noDataCell.TextLabel.Lines = 0;
|
||||
noDataCell.TextLabel.TextColor = ThemeHelpers.TextColor;
|
||||
}
|
||||
|
||||
return noDataCell;
|
||||
}
|
||||
|
||||
var cell = tableView.DequeueReusableCell(CipherLoginCellIdentifier);
|
||||
if (cell is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The cell {CipherLoginCellIdentifier} has not been registered in the UITableView");
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
return new ExtendedUITableViewCell(UITableViewCellStyle.Default, "NoDataCell");
|
||||
}
|
||||
}
|
||||
|
||||
internal void ReloadWithAllowedFido2Credentials(List<string> allowedCipherIds)
|
||||
{
|
||||
_allowedFido2CipherIds = allowedCipherIds;
|
||||
Controller.ReloadItemsAsync().FireAndForget();
|
||||
}
|
||||
|
||||
bool IsPasskeySection(nint section) => section == 0;
|
||||
|
||||
public async override void RowSelected(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
try
|
||||
@ -41,8 +256,26 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
return;
|
||||
}
|
||||
|
||||
await AutofillHelpers.TableRowSelectedAsync(tableView, indexPath, this,
|
||||
Controller.CPViewController, Controller, _passwordRepromptService, LoginAddSegue);
|
||||
if (Items == null || Items.Count() == 0)
|
||||
{
|
||||
Controller.PerformSegue(LoginAddSegue, this);
|
||||
return;
|
||||
}
|
||||
|
||||
var item = await DeselectRowAndGetItemAsync(tableView, indexPath);
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Context.IsPreparingListForPasskey && item.IsFido2ListItem)
|
||||
{
|
||||
Context.PickCredentialForFido2GetAssertionFromListTcs.SetResult(item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await AutofillHelpers.TableRowSelectedAsync(item, this,
|
||||
Controller.CPViewController, Controller, _passwordRepromptService);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -52,13 +285,9 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
|
||||
private async Task SelectRowForPasskeyCreationAsync(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
tableView.DeselectRow(indexPath, true);
|
||||
tableView.EndEditing(true);
|
||||
|
||||
var item = Items.ElementAt(indexPath.Row);
|
||||
var item = await DeselectRowAndGetItemAsync(tableView, indexPath);
|
||||
if (item is null)
|
||||
{
|
||||
await _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -80,6 +309,31 @@ namespace Bit.iOS.Autofill.Utilities
|
||||
|
||||
Context.PickCredentialForFido2CreationTcs.SetResult((item.Id, null));
|
||||
}
|
||||
|
||||
private async Task<CipherViewModel> DeselectRowAndGetItemAsync(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
tableView.DeselectRow(indexPath, true);
|
||||
tableView.EndEditing(true);
|
||||
|
||||
var item = Items.ElementAtOrDefault(GetIndexForItemAt(tableView, indexPath));
|
||||
if (item is null)
|
||||
{
|
||||
await _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
protected override int GetIndexForItemAt(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
var index = indexPath.Row;
|
||||
if (indexPath.Section == 1)
|
||||
{
|
||||
index += (int)RowsInSection(tableView, 0);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities.Fido2;
|
||||
using Bit.iOS.Autofill.Models;
|
||||
|
||||
namespace Bit.iOS.Autofill.Utilities
|
||||
{
|
||||
public class Fido2GetAssertionFromListUserInterface : IFido2GetAssertionUserInterface
|
||||
{
|
||||
private readonly Context _context;
|
||||
private readonly Func<Task> _ensureUnlockedVaultCallback;
|
||||
private readonly Func<bool> _hasVaultBeenUnlockedInThisTransaction;
|
||||
private readonly Func<string, Fido2UserVerificationPreference, Task<bool>> _verifyUserCallback;
|
||||
private readonly Action<List<string>> _onAllowedFido2Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation to perform the interactions with the UI directly from a list.
|
||||
/// </summary>
|
||||
/// <param name="context">Current context</param>
|
||||
/// <param name="ensureUnlockedVaultCallback">Call to ensure the vault is unlocekd</param>
|
||||
/// <param name="hasVaultBeenUnlockedInThisTransaction">Check if vault has been unlocked in this transaction</param>
|
||||
/// <param name="verifyUserCallback">Call to perform user verification to a given cipherId and preference</param>
|
||||
/// <param name="onAllowedFido2Credentials">Action to be performed on allowed Fido2 credentials, each one is a cipherId</param>
|
||||
public Fido2GetAssertionFromListUserInterface(Context context,
|
||||
Func<Task> ensureUnlockedVaultCallback,
|
||||
Func<bool> hasVaultBeenUnlockedInThisTransaction,
|
||||
Func<string, Fido2UserVerificationPreference, Task<bool>> verifyUserCallback,
|
||||
Action<List<string>> onAllowedFido2Credentials)
|
||||
{
|
||||
_context = context;
|
||||
_ensureUnlockedVaultCallback = ensureUnlockedVaultCallback;
|
||||
_hasVaultBeenUnlockedInThisTransaction = hasVaultBeenUnlockedInThisTransaction;
|
||||
_verifyUserCallback = verifyUserCallback;
|
||||
_onAllowedFido2Credentials = onAllowedFido2Credentials;
|
||||
}
|
||||
|
||||
public bool HasVaultBeenUnlockedInThisTransaction { get; private set; }
|
||||
|
||||
public async Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials)
|
||||
{
|
||||
if (credentials is null || credentials.Length == 0)
|
||||
{
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
|
||||
HasVaultBeenUnlockedInThisTransaction = _hasVaultBeenUnlockedInThisTransaction();
|
||||
|
||||
_onAllowedFido2Credentials?.Invoke(credentials.Select(c => c.CipherId).ToList() ?? new List<string>());
|
||||
|
||||
_context.PickCredentialForFido2GetAssertionFromListTcs?.SetCanceled();
|
||||
_context.PickCredentialForFido2GetAssertionFromListTcs = new TaskCompletionSource<string>();
|
||||
|
||||
var cipherId = await _context.PickCredentialForFido2GetAssertionFromListTcs.Task;
|
||||
|
||||
var credential = credentials.First(c => c.CipherId == cipherId);
|
||||
|
||||
var verified = await _verifyUserCallback(cipherId, credential.UserVerificationPreference);
|
||||
|
||||
return (CipherId: cipherId, UserVerified: verified);
|
||||
}
|
||||
|
||||
public async Task EnsureUnlockedVaultAsync()
|
||||
{
|
||||
await _ensureUnlockedVaultCallback();
|
||||
|
||||
HasVaultBeenUnlockedInThisTransaction = _hasVaultBeenUnlockedInThisTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -93,6 +93,7 @@
|
||||
<Compile Include="ILoginListViewController.cs" />
|
||||
<Compile Include="Fido2MakeCredentialUserInterface.cs" />
|
||||
<Compile Include="Utilities\InvalidOperationNeedsUIException.cs" />
|
||||
<Compile Include="Utilities\Fido2GetAssertionFromListUserInterface.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BundleResource Include="Resources\check.png" />
|
||||
|
@ -1,8 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Bit.iOS.Core.Models
|
||||
{
|
||||
@ -30,6 +27,8 @@ namespace Bit.iOS.Core.Models
|
||||
public List<Tuple<string, string>> Fields { get; set; }
|
||||
public CipherView CipherView { get; set; }
|
||||
public CipherRepromptType Reprompt { get; set; }
|
||||
public bool IsFido2ListItem { get; set; }
|
||||
public bool ForceSectionIcon { get; set; }
|
||||
|
||||
public bool HasFido2Credential => CipherView?.HasFido2Credential ?? false;
|
||||
|
||||
@ -46,5 +45,13 @@ namespace Bit.iOS.Core.Models
|
||||
public string Uri { get; set; }
|
||||
public UriMatchType? Match { get; set; }
|
||||
}
|
||||
|
||||
public CipherViewModel ToPasskeyListItemCipherViewModel()
|
||||
{
|
||||
var vm = new CipherViewModel(CipherView);
|
||||
vm.IsFido2ListItem = true;
|
||||
vm.ForceSectionIcon = ForceSectionIcon;
|
||||
return vm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,7 @@ namespace Bit.iOS.Core.Views
|
||||
|
||||
_mainIcon.Font = UIFont.FromName("bwi-font", 24);
|
||||
_mainIcon.AdjustsFontSizeToFitWidth = true;
|
||||
_mainIcon.Text = BitwardenIcons.Globe;
|
||||
_mainIcon.TextColor = ThemeHelpers.PrimaryColor;
|
||||
|
||||
_orgIcon.Font = UIFont.FromName("bwi-font", 15);
|
||||
@ -103,7 +104,7 @@ namespace Bit.iOS.Core.Views
|
||||
|
||||
public void SetSubtitle(string subtitle) => _subtitle.Text = subtitle;
|
||||
|
||||
public void SetHasFido2Credential(bool has) => _mainIcon.Text = has ? BitwardenIcons.Passkey : BitwardenIcons.Globe;
|
||||
public void UpdateMainIcon(bool usePasskeyIcon) => _mainIcon.Text = usePasskeyIcon ? BitwardenIcons.Passkey : BitwardenIcons.Globe;
|
||||
|
||||
public void ShowOrganizationIcon() => _orgIcon.Hidden = false;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Resources.Localization;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.iOS.Core.Controllers;
|
||||
using Bit.iOS.Core.Models;
|
||||
@ -12,9 +13,9 @@ namespace Bit.iOS.Core.Views
|
||||
{
|
||||
public class ExtensionTableSource : ExtendedUITableViewSource
|
||||
{
|
||||
public const string CellIdentifier = nameof(CipherLoginTableViewCell);
|
||||
public const string CipherLoginCellIdentifier = nameof(CipherLoginTableViewCell);
|
||||
|
||||
private IEnumerable<CipherViewModel> _allItems = new List<CipherViewModel>();
|
||||
protected IEnumerable<CipherViewModel> _allItems = new List<CipherViewModel>();
|
||||
protected ICipherService _cipherService;
|
||||
protected ITotpService _totpService;
|
||||
protected IStateService _stateService;
|
||||
@ -34,7 +35,7 @@ namespace Bit.iOS.Core.Views
|
||||
Items = new List<CipherViewModel>();
|
||||
}
|
||||
|
||||
public IEnumerable<CipherViewModel> Items { get; private set; }
|
||||
public IList<CipherViewModel> Items { get; private set; }
|
||||
|
||||
public virtual async Task LoadAsync(bool urlFilter = true, string searchFilter = null)
|
||||
{
|
||||
@ -66,11 +67,11 @@ namespace Bit.iOS.Core.Views
|
||||
|
||||
return combinedLogins
|
||||
.Where(c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted)
|
||||
.Select(s => new CipherViewModel(s))
|
||||
.Select(CreateCipherViewModel)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void FilterResults(string searchFilter, CancellationToken ct)
|
||||
public virtual void FilterResults(string searchFilter, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@ -84,18 +85,24 @@ namespace Bit.iOS.Core.Views
|
||||
var results = _searchService.SearchCiphersAsync(searchFilter,
|
||||
c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted, null, ct)
|
||||
.GetAwaiter().GetResult();
|
||||
Items = results.Select(s => new CipherViewModel(s)).ToArray();
|
||||
Items = results.Select(CreateCipherViewModel).ToList();
|
||||
}
|
||||
|
||||
OnItemsLoaded(searchFilter, ct);
|
||||
}
|
||||
|
||||
protected virtual void OnItemsLoaded(string searchFilter, CancellationToken ct) { }
|
||||
|
||||
protected virtual CipherViewModel CreateCipherViewModel(CipherView cipherView) => new CipherViewModel(cipherView);
|
||||
|
||||
public override nint RowsInSection(UITableView tableview, nint section)
|
||||
{
|
||||
return Items == null || Items.Count() == 0 ? 1 : Items.Count();
|
||||
}
|
||||
|
||||
public void RegisterTableViewCells(UITableView tableView)
|
||||
public virtual void RegisterTableViewCells(UITableView tableView)
|
||||
{
|
||||
tableView.RegisterClassForCellReuse(typeof(CipherLoginTableViewCell), CellIdentifier);
|
||||
tableView.RegisterClassForCellReuse(typeof(CipherLoginTableViewCell), CipherLoginCellIdentifier);
|
||||
}
|
||||
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
|
||||
@ -111,15 +118,17 @@ namespace Bit.iOS.Core.Views
|
||||
return noDataCell;
|
||||
}
|
||||
|
||||
var cell = tableView.DequeueReusableCell(CellIdentifier);
|
||||
var cell = tableView.DequeueReusableCell(CipherLoginCellIdentifier);
|
||||
if (cell is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The cell {CellIdentifier} has not been registered in the UITableView");
|
||||
throw new InvalidOperationException($"The cell {CipherLoginCellIdentifier} has not been registered in the UITableView");
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
public override void WillDisplay(UITableView tableView, UITableViewCell cell, NSIndexPath indexPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Items == null
|
||||
|| !Items.Any()
|
||||
@ -128,7 +137,7 @@ namespace Bit.iOS.Core.Views
|
||||
return;
|
||||
}
|
||||
|
||||
var item = Items.ElementAt(indexPath.Row);
|
||||
var item = Items.ElementAtOrDefault(GetIndexForItemAt(tableView, indexPath));
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
@ -136,21 +145,24 @@ namespace Bit.iOS.Core.Views
|
||||
|
||||
cipherCell.SetTitle(item.Name);
|
||||
cipherCell.SetSubtitle(item.Username);
|
||||
cipherCell.SetHasFido2Credential(item.HasFido2Credential);
|
||||
cipherCell.UpdateMainIcon(ShouldUseMainIconAsPasskey(item, indexPath));
|
||||
if (item.IsShared)
|
||||
{
|
||||
cipherCell.ShowOrganizationIcon();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.LogEvenIfCantBeResolved(ex);
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual int GetIndexForItemAt(UITableView tableView, NSIndexPath indexPath) => indexPath.Row;
|
||||
|
||||
protected virtual bool ShouldUseMainIconAsPasskey(CipherViewModel item, NSIndexPath indexPath) => item.HasFido2Credential;
|
||||
|
||||
public override nfloat GetHeightForRow(UITableView tableView, NSIndexPath indexPath)
|
||||
{
|
||||
if (Items == null
|
||||
|| !Items.Any())
|
||||
{
|
||||
return base.GetHeightForRow(tableView, indexPath);
|
||||
}
|
||||
|
||||
return 55;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user