diff --git a/src/Core/Abstractions/ICipherService.cs b/src/Core/Abstractions/ICipherService.cs index b344bc101..a8c363248 100644 --- a/src/Core/Abstractions/ICipherService.cs +++ b/src/Core/Abstractions/ICipherService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Domain; using Bit.Core.Models.View; @@ -37,5 +34,6 @@ namespace Bit.Core.Abstractions Task DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId); Task SoftDeleteWithServerAsync(string id); Task RestoreWithServerAsync(string id); + Task CreateNewLoginForPasskeyAsync(string rpId); } } diff --git a/src/Core/Resources/Localization/AppResources.Designer.cs b/src/Core/Resources/Localization/AppResources.Designer.cs index 755dff48e..a6651bc7d 100644 --- a/src/Core/Resources/Localization/AppResources.Designer.cs +++ b/src/Core/Resources/Localization/AppResources.Designer.cs @@ -166,15 +166,6 @@ namespace Bit.Core.Resources.Localization { } } - /// - /// Looks up a localized string similar to Credential Provider service - /// - public static string CredentialProviderService { - get { - return ResourceManager.GetString("CredentialProviderService", resourceCulture); - } - } - /// /// Looks up a localized string similar to Bitwarden needs attention - See "Auto-fill Accessibility Service" from Bitwarden settings. /// @@ -1327,6 +1318,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to We were unable to automatically open the Android credential provider settings menu for you. You can navigate to the credential provider settings menu manually from Android Settings > System > Passwords & accounts > Passwords, passkeys and data services.. + /// + public static string BitwardenCredentialProviderGoToSettings { + get { + return ResourceManager.GetString("BitwardenCredentialProviderGoToSettings", resourceCulture); + } + } + /// /// Looks up a localized string similar to Bitwarden Help Center. /// @@ -1543,6 +1543,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Choose a login to save this passkey to. + /// + public static string ChooseALoginToSaveThisPasskeyTo { + get { + return ResourceManager.GetString("ChooseALoginToSaveThisPasskeyTo", resourceCulture); + } + } + /// /// Looks up a localized string similar to Choose file. /// @@ -1903,6 +1912,24 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Credential Provider service. + /// + public static string CredentialProviderService { + get { + return ResourceManager.GetString("CredentialProviderService", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Android Credential Provider is used for managing passkeys for use with websites and other apps on your device.. + /// + public static string CredentialProviderServiceExplanationLong { + get { + return ResourceManager.GetString("CredentialProviderServiceExplanationLong", resourceCulture); + } + } + /// /// Looks up a localized string similar to Credits. /// @@ -5110,6 +5137,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Overwrite passkey?. + /// + public static string OverwritePasskey { + get { + return ResourceManager.GetString("OverwritePasskey", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ownership. /// @@ -5137,6 +5173,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Passkeys for {0}. + /// + public static string PasskeysForX { + get { + return ResourceManager.GetString("PasskeysForX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Passkey will not be copied. /// @@ -5326,6 +5371,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Passwords for {0}. + /// + public static string PasswordsForX { + get { + return ResourceManager.GetString("PasswordsForX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Password type. /// @@ -5831,6 +5885,24 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Save passkey. + /// + public static string SavePasskey { + get { + return ResourceManager.GetString("SavePasskey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save passkey as new login. + /// + public static string SavePasskeyAsNewLogin { + get { + return ResourceManager.GetString("SavePasskeyAsNewLogin", resourceCulture); + } + } + /// /// Looks up a localized string similar to Saving.... /// @@ -6704,6 +6776,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to This item already contains a passkey. Are you sure you want to overwrite the current passkey?. + /// + public static string ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey { + get { + return ResourceManager.GetString("ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey", resourceCulture); + } + } + /// /// Looks up a localized string similar to This request is no longer valid. /// @@ -7694,15 +7775,6 @@ namespace Bit.Core.Resources.Localization { } } - /// - /// Looks up a localized string similar to We were unable to automatically open the Android credential provider settings menu for you. You can navigate to the credential provider settings menu manually from Android Settings > System > Passwords & accounts > Passwords, passkeys and data services. - /// - public static string BitwardenCredentialProviderGoToSettings { - get { - return ResourceManager.GetString("BitwardenCredentialProviderGoToSettings", resourceCulture); - } - } - /// /// Looks up a localized string similar to Word separator. /// @@ -7721,15 +7793,6 @@ namespace Bit.Core.Resources.Localization { } } - /// - /// Looks up a localized string similar to The Android Credential Provider is used for managing passkeys for use with websites and other apps on your device. - /// - public static string CredentialProviderServiceExplanationLong { - get { - return ResourceManager.GetString("CredentialProviderServiceExplanationLong", resourceCulture); - } - } - /// /// Looks up a localized string similar to {0} hours and one minute. /// diff --git a/src/Core/Resources/Localization/AppResources.resx b/src/Core/Resources/Localization/AppResources.resx index a04d4bcb8..1453f8f53 100644 --- a/src/Core/Resources/Localization/AppResources.resx +++ b/src/Core/Resources/Localization/AppResources.resx @@ -2886,4 +2886,25 @@ Do you want to switch to this account? Set up an unlock option to change your vault timeout action. + + Choose a login to save this passkey to + + + Save passkey as new login + + + Save passkey + + + Passkeys for {0} + + + Passwords for {0} + + + Overwrite passkey? + + + This item already contains a passkey. Are you sure you want to overwrite the current passkey? + diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index 357458970..e55a03f9b 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -1286,6 +1286,34 @@ namespace Bit.Core.Services cipher.PasswordHistory = encPhs; } + public async Task CreateNewLoginForPasskeyAsync(string rpId) + { + var newCipher = new CipherView + { + Name = rpId, + Type = CipherType.Login, + Login = new LoginView + { + Uris = new List + { + new LoginUriView { Uri = rpId } + } + }, + Card = new CardView(), + Identity = new IdentityView(), + SecureNote = new SecureNoteView + { + Type = SecureNoteType.Generic + }, + Reprompt = CipherRepromptType.None + }; + + var encryptedCipher = await EncryptAsync(newCipher); + await SaveWithServerAsync(encryptedCipher); + + return encryptedCipher.Id; + } + private class CipherLocaleComparer : IComparer { private readonly II18nService _i18nService; diff --git a/src/iOS.Autofill/ColorConstants.cs b/src/iOS.Autofill/ColorConstants.cs new file mode 100644 index 000000000..19ea72726 --- /dev/null +++ b/src/iOS.Autofill/ColorConstants.cs @@ -0,0 +1,8 @@ +namespace Bit.iOS.Autofill +{ + public static class ColorConstants + { + public const string LIGHT_SECONDARY_300 = "LightSecondary300"; + public const string LIGHT_TEXT_MUTED = "LightTextMuted"; + } +} diff --git a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs index b51827c07..dc8fcd215 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AuthenticationServices; @@ -19,8 +18,6 @@ namespace Bit.iOS.Autofill { public partial class CredentialProviderViewController : ASCredentialProviderViewController, IAccountsManagerHost, IFido2UserInterface { - private readonly LazyResolve _cipherService = new LazyResolve(); - private IFido2AuthenticatorService _fido2AuthService; private IFido2AuthenticatorService Fido2AuthService { @@ -88,6 +85,8 @@ namespace Bit.iOS.Autofill var credIdentity = Runtime.GetNSObject(passkeyRegistrationRequest.CredentialIdentity.GetHandle()); + _context.UrlString = credIdentity?.RelyingPartyIdentifier; + ClipLogger.Log($"PIFPR MakeCredentialAsync"); ClipLogger.Log($"PIFPR MakeCredentialAsync RpID: {credIdentity.RelyingPartyIdentifier}"); ClipLogger.Log($"PIFPR MakeCredentialAsync UserName: {credIdentity.UserName}"); @@ -245,14 +244,14 @@ namespace Bit.iOS.Autofill { try { - ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential"); - if (assertionCredential is null) - { - ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> assertionCredential is null"); - ServiceContainer.Reset(); - CancelRequest(ASExtensionErrorCode.UserCanceled); - return; - } + ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential"); + if (assertionCredential is null) + { + ClipLogger.Log("CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential -> assertionCredential is null"); + ServiceContainer.Reset(); + CancelRequest(ASExtensionErrorCode.UserCanceled); + return; + } //NSRunLoop.Main.BeginInvokeOnMainThread(() => //{ @@ -290,43 +289,28 @@ namespace Bit.iOS.Autofill public Task InformExcludedCredential(string[] existingCipherIds) { + // iOS doesn't seem to provide the ExcludeCredentialDescriptorList so nothing to do here currently. return Task.CompletedTask; } public async Task ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams) { - // TODO: Show interface so the user can choose whether to create a new passkey or select one to add the passkey to. - var newCipher = new CipherView + ClipLogger.Log($"ConfirmNewCredentialAsync"); + _context.ConfirmNewCredentialTcs?.SetCanceled(); + _context.ConfirmNewCredentialTcs = new TaskCompletionSource(); + MainThread.BeginInvokeOnMainThread(() => { - Name = confirmNewCredentialParams.RpId, - Type = Bit.Core.Enums.CipherType.Login, - Login = new LoginView + try { - Uris = new List - { - new LoginUriView - { - Uri = confirmNewCredentialParams.RpId - } + PerformSegue(SegueConstants.LOGIN_LIST, this); } - }, - Card = new CardView(), - Identity = new IdentityView(), - SecureNote = new SecureNoteView + catch (Exception ex) { - Type = Bit.Core.Enums.SecureNoteType.Generic - }, - Reprompt = Bit.Core.Enums.CipherRepromptType.None - }; + LoggerHelper.LogEvenIfCantBeResolved(ex); + } + }); - var encryptedCipher = await _cipherService.Value.EncryptAsync(newCipher); - await _cipherService.Value.SaveWithServerAsync(encryptedCipher); - - return new Fido2ConfirmNewCredentialResult - { - CipherId = encryptedCipher.Id, - UserVerified = true - }; + return await _context.ConfirmNewCredentialTcs.Task; } public async Task EnsureUnlockedVaultAsync() @@ -340,8 +324,8 @@ namespace Bit.iOS.Autofill return; } - _context._unlockVaultTcs?.SetCanceled(); - _context._unlockVaultTcs = new TaskCompletionSource(); + _context.UnlockVaultTcs?.SetCanceled(); + _context.UnlockVaultTcs = new TaskCompletionSource(); MainThread.BeginInvokeOnMainThread(() => { try @@ -356,7 +340,7 @@ namespace Bit.iOS.Autofill }); ClipLogger.Log($"EnsureUnlockedVaultAsync awaiting for unlock"); - await _context._unlockVaultTcs.Task; + await _context.UnlockVaultTcs.Task; return; } diff --git a/src/iOS.Autofill/CredentialProviderViewController.cs b/src/iOS.Autofill/CredentialProviderViewController.cs index c5e98f167..af0d9af2d 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.cs @@ -394,9 +394,16 @@ namespace Bit.iOS.Autofill CancelRequest(ASExtensionErrorCode.Failed); } - private void CancelRequest(ASExtensionErrorCode code) + public void CancelRequest(ASExtensionErrorCode code) { ClipLogger.Log("CancelRequest" + code); + + if (_context?.IsPasskey == true) + { + _context.ConfirmNewCredentialTcs?.TrySetCanceled(); + _context.UnlockVaultTcs?.TrySetCanceled(); + } + //var err = new NSError(new NSString("ASExtensionErrorDomain"), Convert.ToInt32(code), null); var err = new NSError(ASExtensionErrorCodeExtensions.GetDomain(code), (int)code); ExtensionContext?.CancelRequest(err); @@ -470,7 +477,7 @@ namespace Bit.iOS.Autofill if (_context.IsCreatingPasskey) { ClipLogger.Log("OnLockDismissedAsync -> IsCreatingPasskey"); - _context._unlockVaultTcs.SetResult(true); + _context.UnlockVaultTcs.SetResult(true); return; } diff --git a/src/iOS.Autofill/LoginListViewController.cs b/src/iOS.Autofill/LoginListViewController.cs index c84cfcf52..2630d411c 100644 --- a/src/iOS.Autofill/LoginListViewController.cs +++ b/src/iOS.Autofill/LoginListViewController.cs @@ -1,8 +1,11 @@ using System; +using System.Linq; +using System.Threading.Tasks; using Bit.App.Abstractions; using Bit.App.Controls; using Bit.Core.Abstractions; using Bit.Core.Resources.Localization; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.iOS.Autofill.Models; using Bit.iOS.Autofill.Utilities; @@ -17,6 +20,8 @@ namespace Bit.iOS.Autofill { public partial class LoginListViewController : ExtendedUIViewController { + //internal const string HEADER_SECTION_IDENTIFIER = "headerSectionId"; + UIBarButtonItem _cancelButton; UIControl _accountSwitchButton; @@ -34,8 +39,11 @@ namespace Bit.iOS.Autofill AccountSwitchingOverlayView _accountSwitchingOverlayView; AccountSwitchingOverlayHelper _accountSwitchingOverlayHelper; - LazyResolve _broadcasterService = new LazyResolve("broadcasterService"); - LazyResolve _logger = new LazyResolve("logger"); + LazyResolve _broadcasterService = new LazyResolve(); + LazyResolve _cipherService = new LazyResolve(); + LazyResolve _platformUtilsService = new LazyResolve(); + LazyResolve _logger = new LazyResolve(); + bool _alreadyLoadItemsOnce = false; public async override void ViewDidLoad() @@ -46,14 +54,30 @@ namespace Bit.iOS.Autofill SubscribeSyncCompleted(); - NavItem.Title = AppResources.Items; + NavItem.Title = Context.IsCreatingPasskey ? AppResources.SavePasskey : AppResources.Items; _cancelButton.Title = AppResources.Cancel; TableView.RowHeight = UITableView.AutomaticDimension; TableView.EstimatedRowHeight = 44; TableView.BackgroundColor = ThemeHelpers.BackgroundColor; TableView.Source = new TableSource(this); - await ((TableSource)TableView.Source).LoadItemsAsync(); + //TableView.RegisterClassForHeaderFooterViewReuse(typeof(AccountViewCell), HEADER_SECTION_IDENTIFIER); + + await ((TableSource)TableView.Source).LoadAsync(); + + if (Context.IsCreatingPasskey) + { + _headerLabel.Text = AppResources.ChooseALoginToSaveThisPasskeyTo; + _emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, Context.UrlString); + + _emptyViewButton.SetTitle(AppResources.SavePasskeyAsNewLogin, UIControlState.Normal); + _emptyViewButton.Layer.BorderWidth = 2; + _emptyViewButton.Layer.BorderColor = UIColor.FromName(ColorConstants.LIGHT_TEXT_MUTED).CGColor; + _emptyViewButton.Layer.CornerRadius = 10; + _emptyViewButton.ClipsToBounds = true; + + _headerView.Hidden = false; + } _alreadyLoadItemsOnce = true; @@ -91,17 +115,46 @@ namespace Bit.iOS.Autofill private void Cancel() { - CPViewController.CompleteRequest(); + CPViewController.CancelRequest(AuthenticationServices.ASExtensionErrorCode.UserCanceled); } partial void AddBarButton_Activated(UIBarButtonItem sender) { - PerformSegue("loginAddSegue", this); + PerformSegue(SegueConstants.ADD_LOGIN, this); } partial void SearchBarButton_Activated(UIBarButtonItem sender) { - PerformSegue("loginSearchFromListSegue", this); + PerformSegue(SegueConstants.LOGIN_SEARCH_FROM_LIST, this); + } + + partial void EmptyButton_Activated(UIButton sender) + { + ClipLogger.Log($"EmptyButton_Activated"); + SavePasskeyAsNewLoginAsync().FireAndForget(ex => + { + _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred).FireAndForget(); + }); + } + + private async Task SavePasskeyAsNewLoginAsync() + { + if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0)) + { + Context?.ConfirmNewCredentialTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login on iOS less than 17.")); + return; + } + + ClipLogger.Log($"SavePasskeyAsNewLoginAsync "); + + var cipherId = await _cipherService.Value.CreateNewLoginForPasskeyAsync(Context.PasskeyCredentialIdentity.RelyingPartyIdentifier); + + ClipLogger.Log($"SavePasskeyAsNewLoginAsync -> setting result {cipherId}"); + Context.ConfirmNewCredentialTcs.TrySetResult(new Fido2ConfirmNewCredentialResult + { + CipherId = cipherId, + UserVerified = true + }); } public override void PrepareForSegue(UIStoryboardSegue segue, NSObject sender) @@ -136,7 +189,7 @@ namespace Bit.iOS.Autofill { try { - await ((TableSource)TableView.Source).LoadItemsAsync(); + await ((TableSource)TableView.Source).LoadAsync(); TableView.ReloadData(); } catch (Exception ex) @@ -148,6 +201,14 @@ namespace Bit.iOS.Autofill }); } + public void OnEmptyList() + { + ClipLogger.Log($"OnEmptyList"); + _emptyView.Hidden = false; + _headerView.Hidden = false; + TableView.Hidden = true; + } + public override void ViewDidUnload() { base.ViewDidUnload(); @@ -159,8 +220,15 @@ namespace Bit.iOS.Autofill { DismissViewController(true, async () => { - await ((TableSource)TableView.Source).LoadItemsAsync(); - TableView.ReloadData(); + try + { + await ((TableSource)TableView.Source).LoadAsync(); + TableView.ReloadData(); + } + catch (Exception ex) + { + _logger.Value.Exception(ex); + } }); } @@ -181,7 +249,10 @@ namespace Bit.iOS.Autofill public class TableSource : ExtensionTableSource { - private LoginListViewController _controller; + private readonly LoginListViewController _controller; + + private readonly LazyResolve _platformUtilsService = new LazyResolve(); + private readonly LazyResolve _passwordRepromptService = new LazyResolve(); public TableSource(LoginListViewController controller) : base(controller.Context, controller) @@ -189,11 +260,109 @@ namespace Bit.iOS.Autofill _controller = controller; } + private Context Context => (Context)_context; + + //protected override async Task> LoadItemsAsync(bool urlFilter = true, string searchFilter = null) + //{ + // if (!Context.IsCreatingPasskey) + // { + // return await base.LoadItemsAsync(urlFilter, searchFilter); + // } + + + //} + + public override async Task LoadAsync(bool urlFilter = true, string searchFilter = null) + { + await base.LoadAsync(urlFilter, searchFilter); + + if (Context.IsCreatingPasskey && !Items.Any()) + { + _controller?.OnEmptyList(); + } + } + + //public override nint NumberOfSections(UITableView tableView) + //{ + // return Context.IsCreatingPasskey ? 1 : 0; + //} + + //public override UIView GetViewForHeader(UITableView tableView, nint section) + //{ + // if (Context.IsCreatingPasskey) + // { + // var view = tableView.DequeueReusableHeaderFooterView(LoginListViewController.HEADER_SECTION_IDENTIFIER); + + // return view; + // } + + // return base.GetViewForHeader(tableView, section); + //} + + public override nint RowsInSection(UITableView tableview, nint section) + { + if (Context.IsCreatingPasskey) + { + return Items?.Count() ?? 0; + } + + return base.RowsInSection(tableview, section); + } + public async override void RowSelected(UITableView tableView, NSIndexPath indexPath) { + if (Context.IsCreatingPasskey) + { + await SelectRowForPasskeyCreationAsync(tableView, indexPath); + return; + } + await AutofillHelpers.TableRowSelectedAsync(tableView, indexPath, this, _controller.CPViewController, _controller, _controller.PasswordRepromptService, "loginAddSegue"); } + + private async Task SelectRowForPasskeyCreationAsync(UITableView tableView, NSIndexPath indexPath) + { + ClipLogger.Log($"SelectRowForPasskeyCreationAsync"); + + tableView.DeselectRow(indexPath, true); + tableView.EndEditing(true); + + var item = Items.ElementAt(indexPath.Row); + if (item is null) + { + ClipLogger.Log($"SelectRowForPasskeyCreationAsync -> item is null"); + await _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred); + return; + } + + if (item.CipherView.Login.HasFido2Credentials + && + !await _platformUtilsService.Value.ShowDialogAsync( + AppResources.ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey, + AppResources.OverwritePasskey, + AppResources.Yes, + AppResources.No)) + { + ClipLogger.Log($"SelectRowForPasskeyCreationAsync -> don't want to overwrite"); + return; + } + + if (!await _passwordRepromptService.Value.PromptAndCheckPasswordIfNeededAsync(item.Reprompt)) + { + ClipLogger.Log($"SelectRowForPasskeyCreationAsync -> PromptAndCheckPasswordIfNeededAsync -> false"); + return; + } + + // TODO: Check user verification + + ClipLogger.Log($"SelectRowForPasskeyCreationAsync -> Setting result {item.Id}"); + Context.ConfirmNewCredentialTcs.SetResult(new Fido2ConfirmNewCredentialResult + { + CipherId = item.Id, + UserVerified = true + }); + } } } } diff --git a/src/iOS.Autofill/LoginListViewController.designer.cs b/src/iOS.Autofill/LoginListViewController.designer.cs index 6451849c8..cb6929688 100644 --- a/src/iOS.Autofill/LoginListViewController.designer.cs +++ b/src/iOS.Autofill/LoginListViewController.designer.cs @@ -12,6 +12,24 @@ namespace Bit.iOS.Autofill [Register ("LoginListViewController")] partial class LoginListViewController { + [Outlet] + UIKit.UIView _emptyView { get; set; } + + [Outlet] + UIKit.UIButton _emptyViewButton { get; set; } + + [Outlet] + UIKit.UIImageView _emptyViewImage { get; set; } + + [Outlet] + UIKit.UILabel _emptyViewLabel { get; set; } + + [Outlet] + UIKit.UILabel _headerLabel { get; set; } + + [Outlet] + UIKit.UIView _headerView { get; set; } + [Outlet] [GeneratedCode ("iOS Designer", "1.0")] UIKit.UIBarButtonItem AddBarButton { get; set; } @@ -32,11 +50,44 @@ namespace Bit.iOS.Autofill [Action ("AddBarButton_Activated:")] partial void AddBarButton_Activated (UIKit.UIBarButtonItem sender); + [Action ("EmptyButton_Activated:")] + partial void EmptyButton_Activated (UIKit.UIButton sender); + [Action ("SearchBarButton_Activated:")] partial void SearchBarButton_Activated (UIKit.UIBarButtonItem sender); void ReleaseDesignerOutlets () { + if (_emptyView != null) { + _emptyView.Dispose (); + _emptyView = null; + } + + if (_emptyViewButton != null) { + _emptyViewButton.Dispose (); + _emptyViewButton = null; + } + + if (_emptyViewImage != null) { + _emptyViewImage.Dispose (); + _emptyViewImage = null; + } + + if (_emptyViewLabel != null) { + _emptyViewLabel.Dispose (); + _emptyViewLabel = null; + } + + if (_headerLabel != null) { + _headerLabel.Dispose (); + _headerLabel = null; + } + + if (_headerView != null) { + _headerView.Dispose (); + _headerView = null; + } + if (AddBarButton != null) { AddBarButton.Dispose (); AddBarButton = null; diff --git a/src/iOS.Autofill/LoginSearchViewController.cs b/src/iOS.Autofill/LoginSearchViewController.cs index 82f19fcba..dbb2105b4 100644 --- a/src/iOS.Autofill/LoginSearchViewController.cs +++ b/src/iOS.Autofill/LoginSearchViewController.cs @@ -39,7 +39,7 @@ namespace Bit.iOS.Autofill TableView.EstimatedRowHeight = 44; TableView.Source = new TableSource(this); SearchBar.Delegate = new ExtensionSearchDelegate(TableView); - await ((TableSource)TableView.Source).LoadItemsAsync(false, SearchBar.Text); + await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); } public override void ViewDidAppear(bool animated) @@ -88,7 +88,7 @@ namespace Bit.iOS.Autofill { DismissViewController(true, async () => { - await ((TableSource)TableView.Source).LoadItemsAsync(false, SearchBar.Text); + await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); TableView.ReloadData(); }); } diff --git a/src/iOS.Autofill/MainInterface.storyboard b/src/iOS.Autofill/MainInterface.storyboard index 4521ddbc8..5e20f3d6a 100644 --- a/src/iOS.Autofill/MainInterface.storyboard +++ b/src/iOS.Autofill/MainInterface.storyboard @@ -4,6 +4,7 @@ + @@ -131,6 +132,74 @@ + + @@ -173,13 +242,20 @@ + + + + + + + @@ -207,6 +283,12 @@ + + + + + + @@ -575,7 +657,14 @@ + + + + + + + diff --git a/src/iOS.Autofill/Models/Context.cs b/src/iOS.Autofill/Models/Context.cs index 6a1459bb3..72cda24f6 100644 --- a/src/iOS.Autofill/Models/Context.cs +++ b/src/iOS.Autofill/Models/Context.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using AuthenticationServices; +using Bit.Core.Abstractions; using Bit.iOS.Core.Models; using Foundation; using ObjCRuntime; @@ -15,7 +16,8 @@ namespace Bit.iOS.Autofill.Models public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; } public bool Configuring { get; set; } public bool IsCreatingPasskey { get; set; } - public TaskCompletionSource _unlockVaultTcs { get; set; } + public TaskCompletionSource UnlockVaultTcs { get; set; } + public TaskCompletionSource ConfirmNewCredentialTcs { get; set; } public ASPasskeyCredentialIdentity PasskeyCredentialIdentity { diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/Contents.json b/src/iOS.Autofill/Resources/Assets.xcassets/Contents.json new file mode 100755 index 000000000..73c00596a --- /dev/null +++ b/src/iOS.Autofill/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/LightSecondary300.colorset/Contents.json b/src/iOS.Autofill/Resources/Assets.xcassets/LightSecondary300.colorset/Contents.json new file mode 100644 index 000000000..a9b97e061 --- /dev/null +++ b/src/iOS.Autofill/Resources/Assets.xcassets/LightSecondary300.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.863", + "green" : "0.831", + "red" : "0.808" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.924", + "green" : "0.879", + "red" : "0.854" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/LightTextMuted.colorset/Contents.json b/src/iOS.Autofill/Resources/Assets.xcassets/LightTextMuted.colorset/Contents.json new file mode 100644 index 000000000..137c1521a --- /dev/null +++ b/src/iOS.Autofill/Resources/Assets.xcassets/LightTextMuted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.494", + "green" : "0.459", + "red" : "0.427" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.783", + "green" : "0.718", + "red" : "0.671" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json new file mode 100755 index 000000000..2c8771189 --- /dev/null +++ b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "Empty-items-state.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Empty-items-state-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf new file mode 100755 index 000000000..f68a9a3b6 Binary files /dev/null and b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf differ diff --git a/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf new file mode 100755 index 000000000..1aa4dc22f Binary files /dev/null and b/src/iOS.Autofill/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf differ diff --git a/src/iOS.Autofill/SegueConstants.cs b/src/iOS.Autofill/SegueConstants.cs new file mode 100644 index 000000000..d9c5f95c2 --- /dev/null +++ b/src/iOS.Autofill/SegueConstants.cs @@ -0,0 +1,12 @@ +namespace Bit.iOS.Autofill +{ + public static class SegueConstants + { + public const string LOGIN_LIST = "loginListSegue"; + public const string LOCK = "lockPasswordSegue"; + public const string LOGIN_SEARCH = "loginSearchSegue"; + public const string SETUP = "setupSegue"; + public const string ADD_LOGIN = "loginAddSegue"; + public const string LOGIN_SEARCH_FROM_LIST = "loginSearchFromListSegue"; + } +} diff --git a/src/iOS.Autofill/iOS.Autofill.csproj b/src/iOS.Autofill/iOS.Autofill.csproj index 053c3a273..f2610ec52 100644 --- a/src/iOS.Autofill/iOS.Autofill.csproj +++ b/src/iOS.Autofill/iOS.Autofill.csproj @@ -82,6 +82,8 @@ + + diff --git a/src/iOS.Core/Views/ExtensionTableSource.cs b/src/iOS.Core/Views/ExtensionTableSource.cs index 94bda0889..01ac2e248 100644 --- a/src/iOS.Core/Views/ExtensionTableSource.cs +++ b/src/iOS.Core/Views/ExtensionTableSource.cs @@ -1,12 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Bit.Core.Resources.Localization; +using System.Diagnostics; using Bit.Core.Abstractions; using Bit.Core.Models.View; +using Bit.Core.Resources.Localization; using Bit.Core.Utilities; using Bit.iOS.Core.Controllers; using Bit.iOS.Core.Models; @@ -25,7 +20,7 @@ namespace Bit.iOS.Core.Views protected ITotpService _totpService; protected IStateService _stateService; protected ISearchService _searchService; - private AppExtensionContext _context; + protected AppExtensionContext _context; private UIViewController _controller; public ExtensionTableSource(AppExtensionContext context, UIViewController controller) @@ -36,11 +31,19 @@ namespace Bit.iOS.Core.Views _searchService = ServiceContainer.Resolve("searchService"); _context = context; _controller = controller; + + Items = new List(); } public IEnumerable Items { get; private set; } - public async Task LoadItemsAsync(bool urlFilter = true, string searchFilter = null) + public virtual async Task LoadAsync(bool urlFilter = true, string searchFilter = null) + { + _allItems = await LoadItemsAsync(urlFilter, searchFilter); + FilterResults(searchFilter, new CancellationToken()); + } + + protected virtual async Task> LoadItemsAsync(bool urlFilter = true, string? searchFilter = null) { var combinedLogins = new List(); @@ -62,11 +65,10 @@ namespace Bit.iOS.Core.Views combinedLogins.AddRange(logins); } - _allItems = combinedLogins + return combinedLogins .Where(c => c.Type == Bit.Core.Enums.CipherType.Login && !c.IsDeleted) .Select(s => new CipherViewModel(s)) - .ToList() ?? new List(); - FilterResults(searchFilter, new CancellationToken()); + .ToList(); } public void FilterResults(string searchFilter, CancellationToken ct) @@ -87,7 +89,7 @@ namespace Bit.iOS.Core.Views } } - public IEnumerable TableItems { get; set; } + //public IEnumerable TableItems { get; set; } public override nint RowsInSection(UITableView tableview, nint section) { @@ -135,9 +137,9 @@ namespace Bit.iOS.Core.Views cell.DetailTextLabel.Text = item.Username; } - public async Task GetTotpAsync(CipherViewModel item) + public async Task GetTotpAsync(CipherViewModel item) { - string totp = null; + string? totp = null; var accessPremium = await _stateService.CanAccessPremiumAsync(); if (accessPremium || (item?.CipherView.OrganizationUseTotp ?? false)) { diff --git a/src/iOS.Extension/LoginListViewController.cs b/src/iOS.Extension/LoginListViewController.cs index 67c7bf300..90e08754e 100644 --- a/src/iOS.Extension/LoginListViewController.cs +++ b/src/iOS.Extension/LoginListViewController.cs @@ -44,7 +44,7 @@ namespace Bit.iOS.Extension TableView.RowHeight = UITableView.AutomaticDimension; TableView.EstimatedRowHeight = 44; TableView.Source = new TableSource(this); - await ((TableSource)TableView.Source).LoadItemsAsync(); + await ((TableSource)TableView.Source).LoadAsync(); } public bool CanAutoFill() @@ -93,7 +93,7 @@ namespace Bit.iOS.Extension { DismissViewController(true, async () => { - await ((TableSource)TableView.Source).LoadItemsAsync(); + await ((TableSource)TableView.Source).LoadAsync(); TableView.ReloadData(); }); }