1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-21 02:33:36 +00: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:
Federico Maccaroni
2024-03-13 12:06:08 -03:00
committed by GitHub
parent 53aedea93a
commit 144fc7c727
18 changed files with 775 additions and 203 deletions

View File

@@ -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> /// <summary>
/// Looks up a localized string similar to This password was not found in any known data breaches. It should be safe to use.. /// Looks up a localized string similar to This password was not found in any known data breaches. It should be safe to use..
/// </summary> /// </summary>

View File

@@ -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> <value>There was a problem reading your passkey for {0}. Try again later.</value>
<comment>The parameter is the RpId</comment> <comment>The parameter is the RpId</comment>
</data> </data>
<data name="Passwords" xml:space="preserve">
<value>Passwords</value>
</data>
</root> </root>

View File

@@ -109,8 +109,9 @@ namespace Bit.Core.Services
{ {
throw; throw;
} }
catch (Exception) catch (Exception ex)
{ {
LoggerHelper.LogEvenIfCantBeResolved(ex);
throw new UnknownError(); throw new UnknownError();
} }
} }
@@ -203,8 +204,9 @@ namespace Bit.Core.Services
Signature = signature Signature = signature
}; };
} }
catch (Exception) catch (Exception ex)
{ {
LoggerHelper.LogEvenIfCantBeResolved(ex);
throw new UnknownError(); throw new UnknownError();
} }
} }

View File

@@ -24,7 +24,25 @@ namespace Bit.iOS.Autofill
private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>(); private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
private readonly LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>(); private readonly LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
private readonly LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>(); 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) public override async void PrepareInterfaceForPasskeyRegistration(IASCredentialRequest registrationRequest)
{ {
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0)) 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 try
{ {

View File

@@ -88,56 +88,13 @@ namespace Bit.iOS.Autofill
} }
else else
{ {
if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0) if (_context.IsCreatingOrPreparingListForPasskey || _context.ServiceIdentifiers?.Length > 0)
{
PerformSegue(SegueConstants.LOGIN_SEARCH, this);
}
else
{ {
PerformSegue(SegueConstants.LOGIN_LIST, this); 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 else
{ {
PerformSegue(SegueConstants.LOGIN_LIST, this); PerformSegue(SegueConstants.LOGIN_SEARCH, this);
} }
} }
} }
@@ -306,6 +263,8 @@ namespace Bit.iOS.Autofill
return; return;
} }
_context.PickCredentialForFido2GetAssertionFromListTcs?.TrySetCanceled();
if (!string.IsNullOrWhiteSpace(totp)) if (!string.IsNullOrWhiteSpace(totp))
{ {
UIPasteboard.General.String = 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); LoggerHelper.LogEvenIfCantBeResolved(ex);
CancelRequest(ASExtensionErrorCode.Failed); CancelRequest(ASExtensionErrorCode.Failed);

View File

@@ -1,4 +1,5 @@
using Bit.iOS.Autofill.Models; using System.Threading.Tasks;
using Bit.iOS.Autofill.Models;
namespace Bit.iOS.Autofill namespace Bit.iOS.Autofill
{ {
@@ -6,5 +7,8 @@ namespace Bit.iOS.Autofill
{ {
Context Context { get; } Context Context { get; }
CredentialProviderViewController CPViewController { get; } CredentialProviderViewController CPViewController { get; }
void OnItemsLoaded(string searchFilter);
Task ReloadItemsAsync();
void ReloadTableViewData();
} }
} }

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AuthenticationServices;
using Bit.App.Controls; using Bit.App.Controls;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@@ -15,8 +16,8 @@ using Bit.iOS.Core.Controllers;
using Bit.iOS.Core.Utilities; using Bit.iOS.Core.Utilities;
using Bit.iOS.Core.Views; using Bit.iOS.Core.Views;
using CoreFoundation; using CoreFoundation;
using CoreGraphics;
using Foundation; using Foundation;
using Microsoft.Maui.ApplicationModel;
using UIKit; using UIKit;
namespace Bit.iOS.Autofill namespace Bit.iOS.Autofill
@@ -45,8 +46,30 @@ namespace Bit.iOS.Autofill
LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>(); LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
LazyResolve<ILogger> _logger = new LazyResolve<ILogger>(); LazyResolve<ILogger> _logger = new LazyResolve<ILogger>();
LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>(); LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
LazyResolve<IFido2AuthenticatorService> _fido2AuthenticatorService = new LazyResolve<IFido2AuthenticatorService>();
bool _alreadyLoadItemsOnce = false; 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() public async override void ViewDidLoad()
{ {
@@ -58,16 +81,22 @@ namespace Bit.iOS.Autofill
SubscribeSyncCompleted(); SubscribeSyncCompleted();
NavItem.Title = Context.IsCreatingPasskey ? AppResources.SavePasskey : AppResources.Items; NavItem.Title = NavTitle;
_cancelButton.Title = AppResources.Cancel; _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; TableView.BackgroundColor = ThemeHelpers.BackgroundColor;
var tableSource = new TableSource(this); var tableSource = new TableSource(this);
TableView.Source = tableSource; TableView.Source = tableSource;
tableSource.RegisterTableViewCells(TableView); tableSource.RegisterTableViewCells(TableView);
if (Context.IsCreatingPasskey) if (Context.IsCreatingOrPreparingListForPasskey)
{ {
TableView.SectionHeaderHeight = 55; TableView.SectionHeaderHeight = 55;
TableView.RegisterClassForHeaderFooterViewReuse(typeof(HeaderItemView), HEADER_SECTION_IDENTIFIER); TableView.RegisterClassForHeaderFooterViewReuse(typeof(HeaderItemView), HEADER_SECTION_IDENTIFIER);
@@ -78,8 +107,6 @@ namespace Bit.iOS.Autofill
TableView.SectionHeaderTopPadding = 0; TableView.SectionHeaderTopPadding = 0;
} }
await ((TableSource)TableView.Source).LoadAsync();
if (Context.IsCreatingPasskey) if (Context.IsCreatingPasskey)
{ {
_emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, Context.UrlString); _emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, Context.UrlString);
@@ -91,8 +118,6 @@ namespace Bit.iOS.Autofill
_emptyViewButton.ClipsToBounds = true; _emptyViewButton.ClipsToBounds = true;
} }
_alreadyLoadItemsOnce = true;
var storageService = ServiceContainer.Resolve<IStorageService>("storageService"); var storageService = ServiceContainer.Resolve<IStorageService>("storageService");
var needsAutofillReplacement = await storageService.GetAsync<bool?>( var needsAutofillReplacement = await storageService.GetAsync<bool?>(
Core.Constants.AutofillNeedsIdentityReplacementKey); Core.Constants.AutofillNeedsIdentityReplacementKey);
@@ -113,6 +138,22 @@ namespace Bit.iOS.Autofill
}, false); }, false);
_accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); _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) 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) private void CancelButton_TouchUpInside(object sender, EventArgs e)
{ {
Cancel(); Cancel();
@@ -142,7 +251,54 @@ namespace Bit.iOS.Autofill
partial void SearchBarButton_Activated(UIBarButtonItem sender) partial void SearchBarButton_Activated(UIBarButtonItem sender)
{ {
PerformSegue(SegueConstants.LOGIN_SEARCH_FROM_LIST, this); 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) partial void EmptyButton_Activated(UIButton sender)
@@ -250,8 +406,7 @@ namespace Bit.iOS.Autofill
{ {
try try
{ {
await ((TableSource)TableView.Source).LoadAsync(); await ReloadItemsAsync();
TableView.ReloadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -262,10 +417,18 @@ namespace Bit.iOS.Autofill
}); });
} }
public void OnEmptyList() public void OnItemsLoaded(string searchFilter)
{ {
_emptyView.Hidden = false; if (Context.IsCreatingPasskey)
TableView.Hidden = true; {
_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() public override void ViewDidUnload()
@@ -281,8 +444,7 @@ namespace Bit.iOS.Autofill
{ {
try try
{ {
await ((TableSource)TableView.Source).LoadAsync(); await ReloadItemsAsync();
TableView.ReloadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -306,6 +468,53 @@ namespace Bit.iOS.Autofill
base.Dispose(disposing); 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 class TableSource : BaseLoginListTableSource<LoginListViewController>
{ {
public TableSource(LoginListViewController controller) public TableSource(LoginListViewController controller)
@@ -314,54 +523,6 @@ namespace Bit.iOS.Autofill
} }
protected override string LoginAddSegue => SegueConstants.ADD_LOGIN; 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);
}
} }
} }
} }

View File

@@ -24,6 +24,15 @@ namespace Bit.iOS.Autofill
[Outlet] [Outlet]
UIKit.UILabel _emptyViewLabel { get; set; } UIKit.UILabel _emptyViewLabel { get; set; }
[Outlet]
UIKit.UIActivityIndicatorView _loadingView { get; set; }
[Outlet]
UIKit.UISearchBar _searchBar { get; set; }
[Outlet]
UIKit.NSLayoutConstraint _tableViewTopToSearchBarConstraint { get; set; }
[Outlet] [Outlet]
[GeneratedCode ("iOS Designer", "1.0")] [GeneratedCode ("iOS Designer", "1.0")]
UIKit.UIBarButtonItem AddBarButton { get; set; } UIKit.UIBarButtonItem AddBarButton { get; set; }
@@ -72,6 +81,16 @@ namespace Bit.iOS.Autofill
_emptyViewLabel = null; _emptyViewLabel = null;
} }
if (_searchBar != null) {
_searchBar.Dispose ();
_searchBar = null;
}
if (_tableViewTopToSearchBarConstraint != null) {
_tableViewTopToSearchBarConstraint.Dispose ();
_tableViewTopToSearchBarConstraint = null;
}
if (AddBarButton != null) { if (AddBarButton != null) {
AddBarButton.Dispose (); AddBarButton.Dispose ();
AddBarButton = null; AddBarButton = null;
@@ -96,6 +115,11 @@ namespace Bit.iOS.Autofill
TableView.Dispose (); TableView.Dispose ();
TableView = null; TableView = null;
} }
if (_loadingView != null) {
_loadingView.Dispose ();
_loadingView = null;
}
} }
} }
} }

View File

@@ -1,14 +1,14 @@
using System; using System;
using System.Threading.Tasks;
using Bit.Core.Resources.Localization;
using Bit.Core.Services;
using Bit.iOS.Autofill.Models; 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 Foundation;
using UIKit; 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 namespace Bit.iOS.Autofill
{ {
@@ -26,22 +26,35 @@ namespace Bit.iOS.Autofill
public async override void ViewDidLoad() public async override void ViewDidLoad()
{ {
base.ViewDidLoad(); try
NavItem.Title = AppResources.SearchVault; {
CancelBarButton.Title = AppResources.Cancel; base.ViewDidLoad();
SearchBar.Placeholder = AppResources.Search;
SearchBar.BackgroundColor = SearchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor;
SearchBar.UpdateThemeIfNeeded();
TableView.RowHeight = UITableView.AutomaticDimension; NavItem.Title = AppResources.SearchVault;
TableView.EstimatedRowHeight = 44; CancelBarButton.Title = AppResources.Cancel;
SearchBar.Placeholder = AppResources.Search;
var tableSource = new TableSource(this); SearchBar.BackgroundColor = SearchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor;
TableView.Source = tableSource; SearchBar.UpdateThemeIfNeeded();
tableSource.RegisterTableViewCells(TableView);
TableView.RowHeight = UITableView.AutomaticDimension;
SearchBar.Delegate = new ExtensionSearchDelegate(TableView); TableView.EstimatedRowHeight = 55;
await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text);
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 ReloadItemsAsync();
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
} }
public override void ViewDidAppear(bool animated) public override void ViewDidAppear(bool animated)
@@ -90,11 +103,20 @@ namespace Bit.iOS.Autofill
{ {
DismissViewController(true, async () => DismissViewController(true, async () =>
{ {
await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); await ReloadItemsAsync();
TableView.ReloadData();
}); });
} }
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 class TableSource : BaseLoginListTableSource<LoginSearchViewController>
{ {
public TableSource(LoginSearchViewController controller) public TableSource(LoginSearchViewController controller)

View File

@@ -132,6 +132,13 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="830"/> <rect key="frame" x="0.0" y="0.0" width="414" height="830"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <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"> <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"/> <rect key="frame" x="0.0" y="200" width="414" height="228"/>
<subviews> <subviews>
@@ -172,7 +179,10 @@
<constraint firstItem="FDN-Dp-jl3" firstAttribute="top" secondItem="wNm-Sy-bJv" secondAttribute="top" id="kKX-UE-JzG"/> <constraint firstItem="FDN-Dp-jl3" firstAttribute="top" secondItem="wNm-Sy-bJv" secondAttribute="top" id="kKX-UE-JzG"/>
</constraints> </constraints>
</view> </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"/> <rect key="frame" x="0.0" y="0.0" width="414" height="781"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<prototypes> <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="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="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="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="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="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="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="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="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"/> <constraint firstItem="2305" firstAttribute="leading" secondItem="BQW-dG-XMM" secondAttribute="leading" id="xfQ-VQ-yWe"/>
</constraints> </constraints>
<variation key="default">
<mask key="constraints">
<exclude reference="pGe-1e-B4s"/>
</mask>
</variation>
</view> </view>
<toolbarItems/> <toolbarItems/>
<navigationItem key="navigationItem" title="Logins" id="3734"> <navigationItem key="navigationItem" title="Logins" id="3734">
@@ -256,6 +277,9 @@
<outlet property="_emptyViewButton" destination="Gv5-Xt-G9l" id="JHd-sV-VJC"/> <outlet property="_emptyViewButton" destination="Gv5-Xt-G9l" id="JHd-sV-VJC"/>
<outlet property="_emptyViewImage" destination="FDN-Dp-jl3" id="Dzb-p3-tv0"/> <outlet property="_emptyViewImage" destination="FDN-Dp-jl3" id="Dzb-p3-tv0"/>
<outlet property="_emptyViewLabel" destination="tEp-qe-xvE" id="CPZ-it-kVY"/> <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="1845" kind="presentation" identifier="loginAddSegue" modalPresentationStyle="fullScreen" modalTransitionStyle="coverVertical" id="3731"/>
<segue destination="11552" kind="show" identifier="loginSearchFromListSegue" id="12574"/> <segue destination="11552" kind="show" identifier="loginSearchFromListSegue" id="12574"/>
</connections> </connections>
@@ -619,7 +643,7 @@
</scene> </scene>
</scenes> </scenes>
<inferredMetricsTieBreakers> <inferredMetricsTieBreakers>
<segue reference="12959"/> <segue reference="12574"/>
<segue reference="3731"/> <segue reference="3731"/>
</inferredMetricsTieBreakers> </inferredMetricsTieBreakers>
<resources> <resources>

View File

@@ -14,6 +14,7 @@ namespace Bit.iOS.Autofill.Models
public ASCredentialServiceIdentifier[] ServiceIdentifiers { get; set; } public ASCredentialServiceIdentifier[] ServiceIdentifiers { get; set; }
public ASPasswordCredentialIdentity PasswordCredentialIdentity { get; set; } public ASPasswordCredentialIdentity PasswordCredentialIdentity { get; set; }
public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; } public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; }
public ASPasskeyCredentialRequestParameters PasskeyCredentialRequestParameters { get; set; }
public bool Configuring { get; set; } public bool Configuring { get; set; }
public bool IsExecutingWithoutUserInteraction { 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. /// Param: isUserVerified if the user was verified. If null then the verification hasn't been done.
/// </summary> /// </summary>
public TaskCompletionSource<(string cipherId, bool? isUserVerified)> PickCredentialForFido2CreationTcs { get; set; } 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 bool VaultUnlockedDuringThisSession { get; set; }
public ASPasskeyCredentialIdentity PasskeyCredentialIdentity public ASPasskeyCredentialIdentity PasskeyCredentialIdentity
@@ -62,5 +69,9 @@ namespace Bit.iOS.Autofill.Models
} }
public bool IsPasskey => PasskeyCredentialRequest != null; public bool IsPasskey => PasskeyCredentialRequest != null;
public bool IsPreparingListForPasskey => PasskeyCredentialRequestParameters != null;
public bool IsCreatingOrPreparingListForPasskey => IsCreatingPasskey || IsPreparingListForPasskey;
} }
} }

View File

@@ -4,6 +4,7 @@ using Bit.App.Abstractions;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Resources.Localization; using Bit.Core.Resources.Localization;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.iOS.Core.Models;
using Bit.iOS.Core.Utilities; using Bit.iOS.Core.Utilities;
using Bit.iOS.Core.Views; using Bit.iOS.Core.Views;
using Foundation; using Foundation;
@@ -13,26 +14,11 @@ namespace Bit.iOS.Autofill.Utilities
{ {
public static class AutofillHelpers public static class AutofillHelpers
{ {
public async static Task TableRowSelectedAsync(UITableView tableView, NSIndexPath indexPath, public async static Task TableRowSelectedAsync(CipherViewModel item, ExtensionTableSource tableSource,
ExtensionTableSource tableSource, CredentialProviderViewController cpViewController, CredentialProviderViewController cpViewController,
UIViewController controller, IPasswordRepromptService passwordRepromptService, UIViewController controller,
string loginAddSegue) 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)) if (!await passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(item.Reprompt))
{ {
return; return;

View File

@@ -1,13 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Bit.App.Abstractions; using Bit.App.Abstractions;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Resources.Localization; using Bit.Core.Resources.Localization;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.iOS.Autofill.ListItems;
using Bit.iOS.Autofill.Models; 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 Bit.iOS.Core.Views;
using CoreGraphics;
using Foundation; using Foundation;
using UIKit; using UIKit;
@@ -16,9 +24,13 @@ namespace Bit.iOS.Autofill.Utilities
public abstract class BaseLoginListTableSource<T> : ExtensionTableSource public abstract class BaseLoginListTableSource<T> : ExtensionTableSource
where T : UIViewController, ILoginListViewController where T : UIViewController, ILoginListViewController
{ {
private IPasswordRepromptService _passwordRepromptService; private const string NoDataCellIdentifier = "NoDataCellIdentifier";
private readonly IPasswordRepromptService _passwordRepromptService;
private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>(); private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
List<string> _allowedFido2CipherIds = null;
public BaseLoginListTableSource(T controller) public BaseLoginListTableSource(T controller)
: base(controller.Context, controller) : base(controller.Context, controller)
{ {
@@ -31,6 +43,209 @@ namespace Bit.iOS.Autofill.Utilities
protected abstract string LoginAddSegue { get; } 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) public async override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{ {
try try
@@ -41,8 +256,26 @@ namespace Bit.iOS.Autofill.Utilities
return; return;
} }
await AutofillHelpers.TableRowSelectedAsync(tableView, indexPath, this, if (Items == null || Items.Count() == 0)
Controller.CPViewController, Controller, _passwordRepromptService, LoginAddSegue); {
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) catch (Exception ex)
{ {
@@ -52,13 +285,9 @@ namespace Bit.iOS.Autofill.Utilities
private async Task SelectRowForPasskeyCreationAsync(UITableView tableView, NSIndexPath indexPath) private async Task SelectRowForPasskeyCreationAsync(UITableView tableView, NSIndexPath indexPath)
{ {
tableView.DeselectRow(indexPath, true); var item = await DeselectRowAndGetItemAsync(tableView, indexPath);
tableView.EndEditing(true);
var item = Items.ElementAt(indexPath.Row);
if (item is null) if (item is null)
{ {
await _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred);
return; return;
} }
@@ -80,6 +309,31 @@ namespace Bit.iOS.Autofill.Utilities
Context.PickCredentialForFido2CreationTcs.SetResult((item.Id, null)); 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;
}
} }
} }

View File

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

View File

@@ -93,6 +93,7 @@
<Compile Include="ILoginListViewController.cs" /> <Compile Include="ILoginListViewController.cs" />
<Compile Include="Fido2MakeCredentialUserInterface.cs" /> <Compile Include="Fido2MakeCredentialUserInterface.cs" />
<Compile Include="Utilities\InvalidOperationNeedsUIException.cs" /> <Compile Include="Utilities\InvalidOperationNeedsUIException.cs" />
<Compile Include="Utilities\Fido2GetAssertionFromListUserInterface.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<BundleResource Include="Resources\check.png" /> <BundleResource Include="Resources\check.png" />

View File

@@ -1,8 +1,5 @@
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.View; using Bit.Core.Models.View;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Bit.iOS.Core.Models namespace Bit.iOS.Core.Models
{ {
@@ -30,6 +27,8 @@ namespace Bit.iOS.Core.Models
public List<Tuple<string, string>> Fields { get; set; } public List<Tuple<string, string>> Fields { get; set; }
public CipherView CipherView { get; set; } public CipherView CipherView { get; set; }
public CipherRepromptType Reprompt { get; set; } public CipherRepromptType Reprompt { get; set; }
public bool IsFido2ListItem { get; set; }
public bool ForceSectionIcon { get; set; }
public bool HasFido2Credential => CipherView?.HasFido2Credential ?? false; public bool HasFido2Credential => CipherView?.HasFido2Credential ?? false;
@@ -46,5 +45,13 @@ namespace Bit.iOS.Core.Models
public string Uri { get; set; } public string Uri { get; set; }
public UriMatchType? Match { get; set; } public UriMatchType? Match { get; set; }
} }
public CipherViewModel ToPasskeyListItemCipherViewModel()
{
var vm = new CipherViewModel(CipherView);
vm.IsFido2ListItem = true;
vm.ForceSectionIcon = ForceSectionIcon;
return vm;
}
} }
} }

View File

@@ -47,6 +47,7 @@ namespace Bit.iOS.Core.Views
_mainIcon.Font = UIFont.FromName("bwi-font", 24); _mainIcon.Font = UIFont.FromName("bwi-font", 24);
_mainIcon.AdjustsFontSizeToFitWidth = true; _mainIcon.AdjustsFontSizeToFitWidth = true;
_mainIcon.Text = BitwardenIcons.Globe;
_mainIcon.TextColor = ThemeHelpers.PrimaryColor; _mainIcon.TextColor = ThemeHelpers.PrimaryColor;
_orgIcon.Font = UIFont.FromName("bwi-font", 15); _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 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; public void ShowOrganizationIcon() => _orgIcon.Hidden = false;
} }

View File

@@ -1,6 +1,7 @@
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Models.View; using Bit.Core.Models.View;
using Bit.Core.Resources.Localization; using Bit.Core.Resources.Localization;
using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.iOS.Core.Controllers; using Bit.iOS.Core.Controllers;
using Bit.iOS.Core.Models; using Bit.iOS.Core.Models;
@@ -12,9 +13,9 @@ namespace Bit.iOS.Core.Views
{ {
public class ExtensionTableSource : ExtendedUITableViewSource 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 ICipherService _cipherService;
protected ITotpService _totpService; protected ITotpService _totpService;
protected IStateService _stateService; protected IStateService _stateService;
@@ -34,7 +35,7 @@ namespace Bit.iOS.Core.Views
Items = new List<CipherViewModel>(); 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) public virtual async Task LoadAsync(bool urlFilter = true, string searchFilter = null)
{ {
@@ -66,11 +67,11 @@ namespace Bit.iOS.Core.Views
return combinedLogins return combinedLogins
.Where(c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted) .Where(c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted)
.Select(s => new CipherViewModel(s)) .Select(CreateCipherViewModel)
.ToList(); .ToList();
} }
public void FilterResults(string searchFilter, CancellationToken ct) public virtual void FilterResults(string searchFilter, CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -84,18 +85,24 @@ namespace Bit.iOS.Core.Views
var results = _searchService.SearchCiphersAsync(searchFilter, var results = _searchService.SearchCiphersAsync(searchFilter,
c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted, null, ct) c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted, null, ct)
.GetAwaiter().GetResult(); .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) public override nint RowsInSection(UITableView tableview, nint section)
{ {
return Items == null || Items.Count() == 0 ? 1 : Items.Count(); 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) public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
@@ -111,46 +118,51 @@ namespace Bit.iOS.Core.Views
return noDataCell; return noDataCell;
} }
var cell = tableView.DequeueReusableCell(CellIdentifier); var cell = tableView.DequeueReusableCell(CipherLoginCellIdentifier);
if (cell is null) 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; return cell;
} }
public override void WillDisplay(UITableView tableView, UITableViewCell cell, NSIndexPath indexPath) public override void WillDisplay(UITableView tableView, UITableViewCell cell, NSIndexPath indexPath)
{ {
if (Items == null try
|| !Items.Any()
|| !(cell is CipherLoginTableViewCell cipherCell))
{ {
return; if (Items == null
} || !Items.Any()
|| !(cell is CipherLoginTableViewCell cipherCell))
{
return;
}
var item = Items.ElementAt(indexPath.Row); var item = Items.ElementAtOrDefault(GetIndexForItemAt(tableView, indexPath));
if (item is null) if (item is null)
{ {
return; return;
} }
cipherCell.SetTitle(item.Name); cipherCell.SetTitle(item.Name);
cipherCell.SetSubtitle(item.Username); cipherCell.SetSubtitle(item.Username);
cipherCell.SetHasFido2Credential(item.HasFido2Credential); cipherCell.UpdateMainIcon(ShouldUseMainIconAsPasskey(item, indexPath));
if (item.IsShared) if (item.IsShared)
{
cipherCell.ShowOrganizationIcon();
}
}
catch (Exception ex)
{ {
cipherCell.ShowOrganizationIcon(); 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) public override nfloat GetHeightForRow(UITableView tableView, NSIndexPath indexPath)
{ {
if (Items == null
|| !Items.Any())
{
return base.GetHeightForRow(tableView, indexPath);
}
return 55; return 55;
} }