1
0
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:
Federico Maccaroni 2024-03-13 12:06:08 -03:00 committed by GitHub
parent 53aedea93a
commit 144fc7c727
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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>
/// Looks up a localized string similar to This password was not found in any known data breaches. It should be safe to use..
/// </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>
<comment>The parameter is the RpId</comment>
</data>
<data name="Passwords" xml:space="preserve">
<value>Passwords</value>
</data>
</root>

View File

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

View File

@ -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
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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="Fido2MakeCredentialUserInterface.cs" />
<Compile Include="Utilities\InvalidOperationNeedsUIException.cs" />
<Compile Include="Utilities\Fido2GetAssertionFromListUserInterface.cs" />
</ItemGroup>
<ItemGroup>
<BundleResource Include="Resources\check.png" />

View File

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

View File

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

View File

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