From fba407f3b6584bcd36708bc1593b2d366f934210 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Wed, 8 Jun 2022 14:24:01 -0300 Subject: [PATCH] SG-210 Account Switching in Autofill (iOS) (#1909) * SG-210 Set up account switching on Autofill iOS * SG-210 Fix refresh after sync on autofill ciphers, also added account switching on lock view on autofill. Also fix possible crash when scrolling when no items were displayed and also fixed navigation when login in on an automatically logged out account. * SG-210 Added reference on iOS.Core project * Fix formatting on AccountManager * SG-210 Fix background color for dark theme --- src/Android/MainApplication.cs | 10 + src/App/Abstractions/IAccountsManager.cs | 12 + src/App/Abstractions/IAccountsManagerHost.cs | 14 + src/App/App.csproj | 2 + src/App/App.xaml.cs | 201 ++----- .../AccountSwitchingOverlayView.xaml.cs | 4 +- .../AccountManagement/AccountsManager.cs | 227 ++++++++ .../AccountManagement/LockNavigationParams.cs | 14 + .../LoginNavigationParams.cs | 14 + src/Core/Enums/NavigationTarget.cs | 13 + .../CredentialProviderViewController.cs | 90 +++- .../LockPasswordViewController.cs | 24 +- .../LockPasswordViewController.designer.cs | 109 ++-- src/iOS.Autofill/LoginListViewController.cs | 74 ++- .../LoginListViewController.designer.cs | 116 ++-- src/iOS.Autofill/MainInterface.storyboard | 335 +++++++----- src/iOS.Autofill/Utilities/AutofillHelpers.cs | 2 +- .../BaseLockPasswordViewController.cs | 509 ++++++++++++++++++ .../Controllers/LockPasswordViewController.cs | 2 + .../AccountSwitchingOverlayHelper.cs | 69 +++ .../Utilities/ImageSourceExtensions.cs | 44 ++ src/iOS.Core/Utilities/iOSCoreHelpers.cs | 10 + src/iOS.Core/Views/ExtensionTableSource.cs | 19 +- src/iOS.Core/iOS.Core.csproj | 3 + 24 files changed, 1500 insertions(+), 417 deletions(-) create mode 100644 src/App/Abstractions/IAccountsManager.cs create mode 100644 src/App/Abstractions/IAccountsManagerHost.cs create mode 100644 src/App/Utilities/AccountManagement/AccountsManager.cs create mode 100644 src/App/Utilities/AccountManagement/LockNavigationParams.cs create mode 100644 src/App/Utilities/AccountManagement/LoginNavigationParams.cs create mode 100644 src/Core/Enums/NavigationTarget.cs create mode 100644 src/iOS.Core/Controllers/BaseLockPasswordViewController.cs create mode 100644 src/iOS.Core/Utilities/AccountSwitchingOverlayHelper.cs create mode 100644 src/iOS.Core/Utilities/ImageSourceExtensions.cs diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index aadd11564..4b5e706e3 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -20,6 +20,7 @@ using System.Net.Http; using System.Net; using Bit.App.Utilities; using Bit.App.Pages; +using Bit.App.Utilities.AccountManagement; #if !FDROID using Android.Gms.Security; #endif @@ -62,6 +63,15 @@ namespace Bit.Droid ServiceContainer.Resolve("passwordRepromptService"), ServiceContainer.Resolve("cryptoService")); ServiceContainer.Register("verificationActionsFlowHelper", verificationActionsFlowHelper); + + var accountsManager = new AccountsManager( + ServiceContainer.Resolve("broadcasterService"), + ServiceContainer.Resolve("vaultTimeoutService"), + ServiceContainer.Resolve("secureStorageService"), + ServiceContainer.Resolve("stateService"), + ServiceContainer.Resolve("platformUtilsService"), + ServiceContainer.Resolve("authService")); + ServiceContainer.Register("accountsManager", accountsManager); } #if !FDROID if (Build.VERSION.SdkInt <= BuildVersionCodes.Kitkat) diff --git a/src/App/Abstractions/IAccountsManager.cs b/src/App/Abstractions/IAccountsManager.cs new file mode 100644 index 000000000..684230d01 --- /dev/null +++ b/src/App/Abstractions/IAccountsManager.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading.Tasks; +using Bit.App.Models; + +namespace Bit.App.Abstractions +{ + public interface IAccountsManager + { + void Init(Func getOptionsFunc, IAccountsManagerHost accountsManagerHost); + Task NavigateOnAccountChangeAsync(bool? isAuthed = null); + } +} diff --git a/src/App/Abstractions/IAccountsManagerHost.cs b/src/App/Abstractions/IAccountsManagerHost.cs new file mode 100644 index 000000000..17e5ae0e2 --- /dev/null +++ b/src/App/Abstractions/IAccountsManagerHost.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Bit.Core.Enums; + +namespace Bit.App.Abstractions +{ + public interface INavigationParams { } + + public interface IAccountsManagerHost + { + Task SetPreviousPageInfoAsync(); + void Navigate(NavigationTarget navTarget, INavigationParams navParams = null); + Task UpdateThemeAsync(); + } +} diff --git a/src/App/App.csproj b/src/App/App.csproj index 2293da7e1..1a94bff94 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -128,6 +128,7 @@ + @@ -420,5 +421,6 @@ + diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index bf6736836..3b2b1229e 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -6,6 +6,7 @@ using Bit.App.Pages; using Bit.App.Resources; using Bit.App.Services; using Bit.App.Utilities; +using Bit.App.Utilities.AccountManagement; using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -16,7 +17,7 @@ using Xamarin.Forms.Xaml; [assembly: XamlCompilation(XamlCompilationOptions.Compile)] namespace Bit.App { - public partial class App : Application + public partial class App : Application, IAccountsManagerHost { private readonly IBroadcasterService _broadcasterService; private readonly IMessagingService _messagingService; @@ -27,6 +28,7 @@ namespace Bit.App private readonly IAuthService _authService; private readonly IStorageService _secureStorageService; private readonly IDeviceActionService _deviceActionService; + private readonly IAccountsManager _accountsManager; private static bool _isResumed; @@ -47,6 +49,9 @@ namespace Bit.App _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _secureStorageService = ServiceContainer.Resolve("secureStorageService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _accountsManager = ServiceContainer.Resolve("accountsManager"); + + _accountsManager.Init(() => Options, this); Bootstrap(); _broadcasterService.Subscribe(nameof(App), async (message) => @@ -71,30 +76,6 @@ namespace Bit.App _messagingService.Send("showDialogResolve", new Tuple(details.DialogId, confirmed)); }); } - else if (message.Command == "locked") - { - var extras = message.Data as Tuple; - var userId = extras?.Item1; - var userInitiated = extras?.Item2 ?? false; - Device.BeginInvokeOnMainThread(async () => await LockedAsync(userId, userInitiated)); - } - else if (message.Command == "lockVault") - { - await _vaultTimeoutService.LockAsync(true); - } - else if (message.Command == "logout") - { - var extras = message.Data as Tuple; - var userId = extras?.Item1; - var userInitiated = extras?.Item2 ?? true; - var expired = extras?.Item3 ?? false; - Device.BeginInvokeOnMainThread(async () => await LogOutAsync(userId, userInitiated, expired)); - } - else if (message.Command == "loggedOut") - { - // Clean up old migrated key if they ever log out. - await _secureStorageService.RemoveAsync("oldKey"); - } else if (message.Command == "resumed") { if (Device.RuntimePlatform == Device.iOS) @@ -109,22 +90,10 @@ namespace Bit.App await SleptAsync(); } } - else if (message.Command == "addAccount") - { - await AddAccount(); - } - else if (message.Command == "accountAdded") - { - await UpdateThemeAsync(); - } - else if (message.Command == "switchedAccount") - { - await SwitchedAccountAsync(); - } else if (message.Command == "migrated") { await Task.Delay(1000); - await SetMainPageAsync(); + await _accountsManager.NavigateOnAccountChangeAsync(); } else if (message.Command == "popAllAndGoToTabGenerator" || message.Command == "popAllAndGoToTabMyVault" || @@ -168,7 +137,6 @@ namespace Bit.App new NavigationPage(new RemoveMasterPasswordPage())); }); } - }); } @@ -263,102 +231,6 @@ namespace Bit.App new System.Globalization.UmAlQuraCalendar(); } - private async Task LogOutAsync(string userId, bool userInitiated, bool expired) - { - await AppHelpers.LogOutAsync(userId, userInitiated); - await SetMainPageAsync(); - _authService.LogOut(() => - { - if (expired) - { - _platformUtilsService.ShowToast("warning", null, AppResources.LoginExpired); - } - }); - } - - private async Task AddAccount() - { - Device.BeginInvokeOnMainThread(async () => - { - Options.HideAccountSwitcher = false; - Current.MainPage = new NavigationPage(new HomePage(Options)); - }); - } - - private async Task SwitchedAccountAsync() - { - await AppHelpers.OnAccountSwitchAsync(); - Device.BeginInvokeOnMainThread(async () => - { - if (await _vaultTimeoutService.ShouldTimeoutAsync()) - { - await _vaultTimeoutService.ExecuteTimeoutActionAsync(); - } - else - { - await SetMainPageAsync(); - } - await Task.Delay(50); - await UpdateThemeAsync(); - }); - } - - private async Task SetMainPageAsync() - { - var authed = await _stateService.IsAuthenticatedAsync(); - if (authed) - { - if (await _vaultTimeoutService.IsLoggedOutByTimeoutAsync() || - await _vaultTimeoutService.ShouldLogOutByTimeoutAsync()) - { - // TODO implement orgIdentifier flow to SSO Login page, same as email flow below - // var orgIdentifier = await _stateService.GetOrgIdentifierAsync(); - - var email = await _stateService.GetEmailAsync(); - Options.HideAccountSwitcher = await _stateService.GetActiveUserIdAsync() == null; - Current.MainPage = new NavigationPage(new LoginPage(email, Options)); - } - else if (await _vaultTimeoutService.IsLockedAsync() || - await _vaultTimeoutService.ShouldLockAsync()) - { - Current.MainPage = new NavigationPage(new LockPage(Options)); - } - else if (Options.FromAutofillFramework && Options.SaveType.HasValue) - { - Current.MainPage = new NavigationPage(new AddEditPage(appOptions: Options)); - } - else if (Options.Uri != null) - { - Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); - } - else if (Options.CreateSend != null) - { - Current.MainPage = new NavigationPage(new SendAddEditPage(Options)); - } - else - { - Current.MainPage = new TabsPage(Options); - } - } - else - { - Options.HideAccountSwitcher = await _stateService.GetActiveUserIdAsync() == null; - if (await _vaultTimeoutService.IsLoggedOutByTimeoutAsync() || - await _vaultTimeoutService.ShouldLogOutByTimeoutAsync()) - { - // TODO implement orgIdentifier flow to SSO Login page, same as email flow below - // var orgIdentifier = await _stateService.GetOrgIdentifierAsync(); - - var email = await _stateService.GetEmailAsync(); - Current.MainPage = new NavigationPage(new LoginPage(email, Options)); - } - else - { - Current.MainPage = new NavigationPage(new HomePage(Options)); - } - } - } - private async Task ClearCacheIfNeededAsync() { var lastClear = await _stateService.GetLastFileCacheClearAsync(); @@ -420,7 +292,7 @@ namespace Bit.App UpdateThemeAsync(); }; Current.MainPage = new NavigationPage(new HomePage(Options)); - var mainPageTask = SetMainPageAsync(); + var mainPageTask = _accountsManager.NavigateOnAccountChangeAsync(); ServiceContainer.Resolve("platformUtilsService").Init(); } @@ -441,23 +313,8 @@ namespace Bit.App }); } - private async Task LockedAsync(string userId, bool userInitiated) + public async Task SetPreviousPageInfoAsync() { - if (!await _stateService.IsActiveAccountAsync(userId)) - { - _platformUtilsService.ShowToast("info", null, AppResources.AccountLockedSuccessfully); - return; - } - - var autoPromptBiometric = !userInitiated; - if (autoPromptBiometric && Device.RuntimePlatform == Device.iOS) - { - var vaultTimeout = await _stateService.GetVaultTimeoutAsync(); - if (vaultTimeout == 0) - { - autoPromptBiometric = false; - } - } PreviousPageInfo lastPageBeforeLock = null; if (Current.MainPage is TabbedPage tabbedPage && tabbedPage.Navigation.ModalStack.Count > 0) { @@ -483,8 +340,44 @@ namespace Bit.App } } await _stateService.SetPreviousPageInfoAsync(lastPageBeforeLock); - var lockPage = new LockPage(Options, autoPromptBiometric); - Device.BeginInvokeOnMainThread(() => Current.MainPage = new NavigationPage(lockPage)); + } + + public void Navigate(NavigationTarget navTarget, INavigationParams navParams) + { + switch (navTarget) + { + case NavigationTarget.HomeLogin: + Current.MainPage = new NavigationPage(new HomePage(Options)); + break; + case NavigationTarget.Login: + if (navParams is LoginNavigationParams loginParams) + { + Current.MainPage = new NavigationPage(new LoginPage(loginParams.Email, Options)); + } + break; + case NavigationTarget.Lock: + if (navParams is LockNavigationParams lockParams) + { + Current.MainPage = new NavigationPage(new LockPage(Options, lockParams.AutoPromptBiometric)); + } + else + { + Current.MainPage = new NavigationPage(new LockPage(Options)); + } + break; + case NavigationTarget.Home: + Current.MainPage = new TabsPage(Options); + break; + case NavigationTarget.AddEditCipher: + Current.MainPage = new NavigationPage(new AddEditPage(appOptions: Options)); + break; + case NavigationTarget.AutofillCiphers: + Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); + break; + case NavigationTarget.SendAddEdit: + Current.MainPage = new NavigationPage(new SendAddEditPage(Options)); + break; + } } } } diff --git a/src/App/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs b/src/App/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs index 8096f9e19..89a0777d3 100644 --- a/src/App/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs +++ b/src/App/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs @@ -63,6 +63,8 @@ namespace Bit.App.Controls public int AccountListRowHeight => Device.RuntimePlatform == Device.Android ? 74 : 70; + public bool LongPressAccountEnabled { get; set; } = true; + public async Task ToggleVisibilityAsync() { if (IsVisible) @@ -167,7 +169,7 @@ namespace Bit.App.Controls private async Task LongPressAccountAsync(AccountViewCellViewModel item) { - if (!item.IsAccount) + if (!LongPressAccountEnabled || !item.IsAccount) { return; } diff --git a/src/App/Utilities/AccountManagement/AccountsManager.cs b/src/App/Utilities/AccountManagement/AccountsManager.cs new file mode 100644 index 000000000..875078af9 --- /dev/null +++ b/src/App/Utilities/AccountManagement/AccountsManager.cs @@ -0,0 +1,227 @@ +using System; +using System.Threading.Tasks; +using Bit.App.Abstractions; +using Bit.App.Models; +using Bit.App.Resources; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Models.Domain; +using Xamarin.Forms; + +namespace Bit.App.Utilities.AccountManagement +{ + public static class AccountsManagerMessageCommands + { + public const string LOCKED = "locked"; + public const string LOCK_VAULT = "lockVault"; + public const string LOGOUT = "logout"; + public const string LOGGED_OUT = "loggedOut"; + public const string ADD_ACCOUNT = "addAccount"; + public const string ACCOUNT_ADDED = "accountAdded"; + public const string SWITCHED_ACCOUNT = "switchedAccount"; + } + + public class AccountsManager : IAccountsManager + { + private readonly IBroadcasterService _broadcasterService; + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly IStorageService _secureStorageService; + private readonly IStateService _stateService; + private readonly IPlatformUtilsService _platformUtilsService; + private readonly IAuthService _authService; + + Func _getOptionsFunc; + private IAccountsManagerHost _accountsManagerHost; + + public AccountsManager(IBroadcasterService broadcasterService, + IVaultTimeoutService vaultTimeoutService, + IStorageService secureStorageService, + IStateService stateService, + IPlatformUtilsService platformUtilsService, + IAuthService authService) + { + _broadcasterService = broadcasterService; + _vaultTimeoutService = vaultTimeoutService; + _secureStorageService = secureStorageService; + _stateService = stateService; + _platformUtilsService = platformUtilsService; + _authService = authService; + } + + private AppOptions Options => _getOptionsFunc?.Invoke() ?? new AppOptions { IosExtension = true }; + + public void Init(Func getOptionsFunc, IAccountsManagerHost accountsManagerHost) + { + _getOptionsFunc = getOptionsFunc; + _accountsManagerHost = accountsManagerHost; + + _broadcasterService.Subscribe(nameof(AccountsManager), OnMessage); + } + + public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null) + { + // TODO: this could be improved by doing chain of responsability pattern + // but for now it may be an overkill, if logic gets more complex consider refactoring it + + var authed = isAuthed ?? await _stateService.IsAuthenticatedAsync(); + if (authed) + { + if (await _vaultTimeoutService.IsLoggedOutByTimeoutAsync() || + await _vaultTimeoutService.ShouldLogOutByTimeoutAsync()) + { + // TODO implement orgIdentifier flow to SSO Login page, same as email flow below + // var orgIdentifier = await _stateService.GetOrgIdentifierAsync(); + + var email = await _stateService.GetEmailAsync(); + Options.HideAccountSwitcher = await _stateService.GetActiveUserIdAsync() == null; + _accountsManagerHost.Navigate(NavigationTarget.Login, new LoginNavigationParams(email)); + } + else if (await _vaultTimeoutService.IsLockedAsync() || + await _vaultTimeoutService.ShouldLockAsync()) + { + _accountsManagerHost.Navigate(NavigationTarget.Lock); + } + else if (Options.FromAutofillFramework && Options.SaveType.HasValue) + { + _accountsManagerHost.Navigate(NavigationTarget.AddEditCipher); + } + else if (Options.Uri != null) + { + _accountsManagerHost.Navigate(NavigationTarget.AutofillCiphers); + } + else if (Options.CreateSend != null) + { + _accountsManagerHost.Navigate(NavigationTarget.SendAddEdit); + } + else + { + _accountsManagerHost.Navigate(NavigationTarget.Home); + } + } + else + { + Options.HideAccountSwitcher = await _stateService.GetActiveUserIdAsync() == null; + if (await _vaultTimeoutService.IsLoggedOutByTimeoutAsync() || + await _vaultTimeoutService.ShouldLogOutByTimeoutAsync()) + { + // TODO implement orgIdentifier flow to SSO Login page, same as email flow below + // var orgIdentifier = await _stateService.GetOrgIdentifierAsync(); + + var email = await _stateService.GetEmailAsync(); + _accountsManagerHost.Navigate(NavigationTarget.Login, new LoginNavigationParams(email)); + } + else + { + _accountsManagerHost.Navigate(NavigationTarget.HomeLogin); + } + } + } + + private async void OnMessage(Message message) + { + switch (message.Command) + { + case AccountsManagerMessageCommands.LOCKED: + Locked(message.Data as Tuple); + break; + case AccountsManagerMessageCommands.LOCK_VAULT: + await _vaultTimeoutService.LockAsync(true); + break; + case AccountsManagerMessageCommands.LOGOUT: + LogOut(message.Data as Tuple); + break; + case AccountsManagerMessageCommands.LOGGED_OUT: + // Clean up old migrated key if they ever log out. + await _secureStorageService.RemoveAsync("oldKey"); + break; + case AccountsManagerMessageCommands.ADD_ACCOUNT: + AddAccount(); + break; + case AccountsManagerMessageCommands.ACCOUNT_ADDED: + await _accountsManagerHost.UpdateThemeAsync(); + break; + case AccountsManagerMessageCommands.SWITCHED_ACCOUNT: + await SwitchedAccountAsync(); + break; + } + } + + private void Locked(Tuple extras) + { + var userId = extras?.Item1; + var userInitiated = extras?.Item2 ?? false; + Device.BeginInvokeOnMainThread(async () => await LockedAsync(userId, userInitiated)); + } + + private async Task LockedAsync(string userId, bool userInitiated) + { + if (!await _stateService.IsActiveAccountAsync(userId)) + { + _platformUtilsService.ShowToast("info", null, AppResources.AccountLockedSuccessfully); + return; + } + + var autoPromptBiometric = !userInitiated; + if (autoPromptBiometric && Device.RuntimePlatform == Device.iOS) + { + var vaultTimeout = await _stateService.GetVaultTimeoutAsync(); + if (vaultTimeout == 0) + { + autoPromptBiometric = false; + } + } + + await _accountsManagerHost.SetPreviousPageInfoAsync(); + + Device.BeginInvokeOnMainThread(() => _accountsManagerHost.Navigate(NavigationTarget.Lock, new LockNavigationParams(autoPromptBiometric))); + } + + private void AddAccount() + { + Device.BeginInvokeOnMainThread(() => + { + Options.HideAccountSwitcher = false; + _accountsManagerHost.Navigate(NavigationTarget.HomeLogin); + }); + } + + private void LogOut(Tuple extras) + { + var userId = extras?.Item1; + var userInitiated = extras?.Item2 ?? true; + var expired = extras?.Item3 ?? false; + Device.BeginInvokeOnMainThread(async () => await LogOutAsync(userId, userInitiated, expired)); + } + + private async Task LogOutAsync(string userId, bool userInitiated, bool expired) + { + await AppHelpers.LogOutAsync(userId, userInitiated); + await NavigateOnAccountChangeAsync(); + _authService.LogOut(() => + { + if (expired) + { + _platformUtilsService.ShowToast("warning", null, AppResources.LoginExpired); + } + }); + } + + private async Task SwitchedAccountAsync() + { + await AppHelpers.OnAccountSwitchAsync(); + Device.BeginInvokeOnMainThread(async () => + { + if (await _vaultTimeoutService.ShouldTimeoutAsync()) + { + await _vaultTimeoutService.ExecuteTimeoutActionAsync(); + } + else + { + await NavigateOnAccountChangeAsync(); + } + await Task.Delay(50); + await _accountsManagerHost.UpdateThemeAsync(); + }); + } + } +} diff --git a/src/App/Utilities/AccountManagement/LockNavigationParams.cs b/src/App/Utilities/AccountManagement/LockNavigationParams.cs new file mode 100644 index 000000000..00ee04cbe --- /dev/null +++ b/src/App/Utilities/AccountManagement/LockNavigationParams.cs @@ -0,0 +1,14 @@ +using Bit.App.Abstractions; + +namespace Bit.App.Utilities.AccountManagement +{ + public class LockNavigationParams : INavigationParams + { + public LockNavigationParams(bool autoPromptBiometric = true) + { + AutoPromptBiometric = autoPromptBiometric; + } + + public bool AutoPromptBiometric { get; } + } +} diff --git a/src/App/Utilities/AccountManagement/LoginNavigationParams.cs b/src/App/Utilities/AccountManagement/LoginNavigationParams.cs new file mode 100644 index 000000000..2e0f9749c --- /dev/null +++ b/src/App/Utilities/AccountManagement/LoginNavigationParams.cs @@ -0,0 +1,14 @@ +using Bit.App.Abstractions; + +namespace Bit.App.Utilities.AccountManagement +{ + public class LoginNavigationParams : INavigationParams + { + public LoginNavigationParams(string email) + { + Email = email; + } + + public string Email { get; } + } +} diff --git a/src/Core/Enums/NavigationTarget.cs b/src/Core/Enums/NavigationTarget.cs new file mode 100644 index 000000000..d226ff283 --- /dev/null +++ b/src/Core/Enums/NavigationTarget.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Enums +{ + public enum NavigationTarget + { + HomeLogin, + Login, + Lock, + Home, + AddEditCipher, + AutofillCiphers, + SendAddEdit + } +} diff --git a/src/iOS.Autofill/CredentialProviderViewController.cs b/src/iOS.Autofill/CredentialProviderViewController.cs index e8ae87b4b..298a6e1cb 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.cs @@ -1,27 +1,32 @@ -using AuthenticationServices; +using System; +using System.Threading.Tasks; +using AuthenticationServices; using Bit.App.Abstractions; +using Bit.App.Models; +using Bit.App.Pages; +using Bit.App.Utilities; +using Bit.App.Utilities.AccountManagement; using Bit.Core.Abstractions; +using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.iOS.Autofill.Models; using Bit.iOS.Core.Utilities; -using Foundation; -using System; -using System.Threading.Tasks; -using Bit.App.Pages; -using UIKit; -using Xamarin.Forms; -using Bit.App.Utilities; -using Bit.App.Models; using Bit.iOS.Core.Views; using CoreNFC; +using Foundation; +using UIKit; +using Xamarin.Forms; namespace Bit.iOS.Autofill { - public partial class CredentialProviderViewController : ASCredentialProviderViewController + public partial class CredentialProviderViewController : ASCredentialProviderViewController, IAccountsManagerHost { private Context _context; private NFCNdefReaderSession _nfcSession = null; private Core.NFCReaderDelegate _nfcDelegate = null; + private IAccountsManager _accountsManager; + + private readonly LazyResolve _stateService = new LazyResolve("stateService"); public CredentialProviderViewController(IntPtr handle) : base(handle) @@ -56,7 +61,7 @@ namespace Bit.iOS.Autofill } if (!await IsAuthed()) { - LaunchHomePage(); + await _accountsManager.NavigateOnAccountChangeAsync(false); } else if (await IsLocked()) { @@ -78,9 +83,8 @@ namespace Bit.iOS.Autofill public override async void ProvideCredentialWithoutUserInteraction(ASPasswordCredentialIdentity credentialIdentity) { InitAppIfNeeded(); - var stateService = ServiceContainer.Resolve("stateService"); - await stateService.SetPasswordRepromptAutofillAsync(false); - await stateService.SetPasswordVerifiedAutofillAsync(false); + await _stateService.Value.SetPasswordRepromptAutofillAsync(false); + await _stateService.Value.SetPasswordVerifiedAutofillAsync(false); if (!await IsAuthed() || await IsLocked()) { var err = new NSError(new NSString("ASExtensionErrorDomain"), @@ -97,7 +101,7 @@ namespace Bit.iOS.Autofill InitAppIfNeeded(); if (!await IsAuthed()) { - LaunchHomePage(); + await _accountsManager.NavigateOnAccountChangeAsync(false); return; } _context.CredentialIdentity = credentialIdentity; @@ -110,7 +114,7 @@ namespace Bit.iOS.Autofill _context.Configuring = true; if (!await IsAuthed()) { - LaunchHomePage(); + await _accountsManager.NavigateOnAccountChangeAsync(false); return; } CheckLock(() => PerformSegue("setupSegue", this)); @@ -229,7 +233,6 @@ namespace Bit.iOS.Autofill return; } - var stateService = ServiceContainer.Resolve("stateService"); var decCipher = await cipher.DecryptAsync(); if (decCipher.Reprompt != Bit.Core.Enums.CipherRepromptType.None) { @@ -237,13 +240,13 @@ namespace Bit.iOS.Autofill // already verified the password. if (!userInteraction) { - await stateService.SetPasswordRepromptAutofillAsync(true); + await _stateService.Value.SetPasswordRepromptAutofillAsync(true); var err = new NSError(new NSString("ASExtensionErrorDomain"), Convert.ToInt32(ASExtensionErrorCode.UserInteractionRequired), null); ExtensionContext?.CancelRequest(err); return; } - else if (!await stateService.GetPasswordVerifiedAutofillAsync()) + else if (!await _stateService.Value.GetPasswordVerifiedAutofillAsync()) { // Add a timeout to resolve keyboard not always showing up. await Task.Delay(250); @@ -258,10 +261,10 @@ namespace Bit.iOS.Autofill } } string totpCode = null; - var disableTotpCopy = await stateService.GetDisableAutoTotpCopyAsync(); + var disableTotpCopy = await _stateService.Value.GetDisableAutoTotpCopyAsync(); if (!disableTotpCopy.GetValueOrDefault(false)) { - var canAccessPremiumAsync = await stateService.CanAccessPremiumAsync(); + var canAccessPremiumAsync = await _stateService.Value.CanAccessPremiumAsync(); if (!string.IsNullOrWhiteSpace(decCipher.Login.Totp) && (canAccessPremiumAsync || cipher.OrganizationUseTotp)) { @@ -275,8 +278,7 @@ namespace Bit.iOS.Autofill private async void CheckLock(Action notLockedAction) { - var stateService = ServiceContainer.Resolve("stateService"); - if (await IsLocked() || await stateService.GetPasswordRepromptAutofillAsync()) + if (await IsLocked() || await _stateService.Value.GetPasswordRepromptAutofillAsync()) { PerformSegue("lockPasswordSegue", this); } @@ -294,8 +296,7 @@ namespace Bit.iOS.Autofill private Task IsAuthed() { - var stateService = ServiceContainer.Resolve("stateService"); - return stateService.IsAuthenticatedAsync(); + return _stateService.Value.IsAuthenticatedAsync(); } private void LogoutIfAuthed() @@ -304,8 +305,7 @@ namespace Bit.iOS.Autofill { if (await IsAuthed()) { - var stateService = ServiceContainer.Resolve("stateService"); - await AppHelpers.LogOutAsync(await stateService.GetActiveUserIdAsync()); + await AppHelpers.LogOutAsync(await _stateService.Value.GetActiveUserIdAsync()); var deviceActionService = ServiceContainer.Resolve("deviceActionService"); if (deviceActionService.SystemMajorVersion() >= 12) { @@ -331,12 +331,16 @@ namespace Bit.iOS.Autofill Bit.Core.Constants.iOSAutoFillClearCiphersCacheKey, Bit.Core.Constants.iOSAllClearCipherCacheKeys); iOSCoreHelpers.InitLogger(); iOSCoreHelpers.Bootstrap(); - var app = new App.App(new AppOptions { IosExtension = true }); + var appOptions = new AppOptions { IosExtension = true }; + var app = new App.App(appOptions); ThemeManager.SetTheme(app.Resources); iOSCoreHelpers.AppearanceAdjustments(); _nfcDelegate = new Core.NFCReaderDelegate((success, message) => messagingService.Send("gotYubiKeyOTP", message)); iOSCoreHelpers.SubscribeBroadcastReceiver(this, _nfcSession, _nfcDelegate); + + _accountsManager = ServiceContainer.Resolve("accountsManager"); + _accountsManager.Init(() => appOptions, this); } private void InitAppIfNeeded() @@ -514,5 +518,35 @@ namespace Bit.iOS.Autofill updateTempPasswordController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; PresentViewController(updateTempPasswordController, true, null); } + + public Task SetPreviousPageInfoAsync() => Task.CompletedTask; + public Task UpdateThemeAsync() => Task.CompletedTask; + + public void Navigate(NavigationTarget navTarget, INavigationParams navParams = null) + { + switch (navTarget) + { + case NavigationTarget.HomeLogin: + DismissViewController(false, () => LaunchHomePage()); + break; + case NavigationTarget.Login: + if (navParams is LoginNavigationParams loginParams) + { + DismissViewController(false, () => LaunchLoginFlow(loginParams.Email)); + } + else + { + DismissViewController(false, () => LaunchLoginFlow()); + } + break; + case NavigationTarget.Lock: + DismissViewController(false, () => PerformSegue("lockPasswordSegue", this)); + break; + case NavigationTarget.AutofillCiphers: + case NavigationTarget.Home: + DismissViewController(false, () => PerformSegue("loginListSegue", this)); + break; + } + } } } diff --git a/src/iOS.Autofill/LockPasswordViewController.cs b/src/iOS.Autofill/LockPasswordViewController.cs index d1cf7e009..1913d60a0 100644 --- a/src/iOS.Autofill/LockPasswordViewController.cs +++ b/src/iOS.Autofill/LockPasswordViewController.cs @@ -1,10 +1,17 @@ using System; +using Bit.App.Controls; +using Bit.iOS.Core.Utilities; using UIKit; namespace Bit.iOS.Autofill { - public partial class LockPasswordViewController : Core.Controllers.LockPasswordViewController + public partial class LockPasswordViewController : Core.Controllers.BaseLockPasswordViewController { + AccountSwitchingOverlayView _accountSwitchingOverlayView; + AccountSwitchingOverlayHelper _accountSwitchingOverlayHelper; + + public override UITableView TableView => MainTableView; + public LockPasswordViewController(IntPtr handle) : base(handle) { @@ -20,6 +27,21 @@ namespace Bit.iOS.Autofill public override Action Success => () => CPViewController.DismissLockAndContinue(); public override Action Cancel => () => CPViewController.CompleteRequest(); + public override async void ViewDidLoad() + { + base.ViewDidLoad(); + + _accountSwitchingOverlayHelper = new AccountSwitchingOverlayHelper(); + AccountSwitchingBarButton.Image = await _accountSwitchingOverlayHelper.CreateAvatarImageAsync(); + + _accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); + } + + partial void AccountSwitchingBarButton_Activated(UIBarButtonItem sender) + { + _accountSwitchingOverlayHelper.OnToolbarItemActivated(_accountSwitchingOverlayView, OverlayView); + } + partial void SubmitButton_Activated(UIBarButtonItem sender) { var task = CheckPasswordAsync(); diff --git a/src/iOS.Autofill/LockPasswordViewController.designer.cs b/src/iOS.Autofill/LockPasswordViewController.designer.cs index 3b37484c6..4fa4f737e 100644 --- a/src/iOS.Autofill/LockPasswordViewController.designer.cs +++ b/src/iOS.Autofill/LockPasswordViewController.designer.cs @@ -1,64 +1,79 @@ // WARNING // -// This file has been generated automatically by Visual Studio from the outlets and -// actions declared in your storyboard file. -// Manual changes to this file will not be maintained. +// This file has been generated automatically by Visual Studio to store outlets and +// actions made in the UI designer. If it is removed, they will be lost. +// Manual changes to this file may not be handled correctly. // using Foundation; -using System; using System.CodeDom.Compiler; -using UIKit; namespace Bit.iOS.Autofill { - [Register ("LockPasswordViewController")] - partial class LockPasswordViewController - { - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UIBarButtonItem CancelButton { get; set; } + [Register ("LockPasswordViewController")] + partial class LockPasswordViewController + { + [Outlet] + UIKit.UIBarButtonItem AccountSwitchingBarButton { get; set; } - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UITableView MainTableView { get; set; } + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UIBarButtonItem CancelButton { get; set; } - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UINavigationItem NavItem { get; set; } + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UITableView MainTableView { get; set; } - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UIBarButtonItem SubmitButton { get; set; } + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UINavigationItem NavItem { get; set; } - [Action ("CancelButton_Activated:")] - [GeneratedCode ("iOS Designer", "1.0")] - partial void CancelButton_Activated (UIKit.UIBarButtonItem sender); + [Outlet] + UIKit.UIView OverlayView { get; set; } - [Action ("SubmitButton_Activated:")] - [GeneratedCode ("iOS Designer", "1.0")] - partial void SubmitButton_Activated (UIKit.UIBarButtonItem sender); + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UIBarButtonItem SubmitButton { get; set; } - void ReleaseDesignerOutlets () - { - if (CancelButton != null) { - CancelButton.Dispose (); - CancelButton = null; - } + [Action ("AccountSwitchingBarButton_Activated:")] + partial void AccountSwitchingBarButton_Activated (UIKit.UIBarButtonItem sender); - if (MainTableView != null) { - MainTableView.Dispose (); - MainTableView = null; - } + [Action ("CancelButton_Activated:")] + partial void CancelButton_Activated (UIKit.UIBarButtonItem sender); - if (NavItem != null) { - NavItem.Dispose (); - NavItem = null; - } + [Action ("SubmitButton_Activated:")] + partial void SubmitButton_Activated (UIKit.UIBarButtonItem sender); + + void ReleaseDesignerOutlets () + { + if (AccountSwitchingBarButton != null) { + AccountSwitchingBarButton.Dispose (); + AccountSwitchingBarButton = null; + } - if (SubmitButton != null) { - SubmitButton.Dispose (); - SubmitButton = null; - } - } - } -} \ No newline at end of file + if (CancelButton != null) { + CancelButton.Dispose (); + CancelButton = null; + } + + if (MainTableView != null) { + MainTableView.Dispose (); + MainTableView = null; + } + + if (NavItem != null) { + NavItem.Dispose (); + NavItem = null; + } + + if (SubmitButton != null) { + SubmitButton.Dispose (); + SubmitButton = null; + } + + if (OverlayView != null) { + OverlayView.Dispose (); + OverlayView = null; + } + } + } +} diff --git a/src/iOS.Autofill/LoginListViewController.cs b/src/iOS.Autofill/LoginListViewController.cs index 3233be168..59691c505 100644 --- a/src/iOS.Autofill/LoginListViewController.cs +++ b/src/iOS.Autofill/LoginListViewController.cs @@ -1,19 +1,21 @@ using System; +using Bit.App.Abstractions; +using Bit.App.Controls; +using Bit.App.Resources; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; 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 CoreFoundation; using Foundation; using UIKit; -using Bit.iOS.Core.Controllers; -using Bit.App.Resources; -using Bit.iOS.Core.Views; -using Bit.iOS.Autofill.Utilities; -using Bit.iOS.Core.Utilities; -using Bit.Core.Utilities; -using Bit.Core.Abstractions; -using Bit.App.Abstractions; namespace Bit.iOS.Autofill { - public partial class LoginListViewController : ExtendedUITableViewController + public partial class LoginListViewController : ExtendedUIViewController { public LoginListViewController(IntPtr handle) : base(handle) @@ -26,17 +28,30 @@ namespace Bit.iOS.Autofill public CredentialProviderViewController CPViewController { get; set; } public IPasswordRepromptService PasswordRepromptService { get; private set; } + AccountSwitchingOverlayView _accountSwitchingOverlayView; + AccountSwitchingOverlayHelper _accountSwitchingOverlayHelper; + + LazyResolve _broadcasterService = new LazyResolve("broadcasterService"); + LazyResolve _logger = new LazyResolve("logger"); + bool _alreadyLoadItemsOnce = false; + public async override void ViewDidLoad() { base.ViewDidLoad(); + + SubscribeSyncCompleted(); + NavItem.Title = AppResources.Items; CancelBarButton.Title = AppResources.Cancel; TableView.RowHeight = UITableView.AutomaticDimension; TableView.EstimatedRowHeight = 44; + TableView.BackgroundColor = ThemeHelpers.BackgroundColor; TableView.Source = new TableSource(this); await ((TableSource)TableView.Source).LoadItemsAsync(); + _alreadyLoadItemsOnce = true; + var storageService = ServiceContainer.Resolve("storageService"); var needsAutofillReplacement = await storageService.GetAsync( Core.Constants.AutofillNeedsIdentityReplacementKey); @@ -44,6 +59,16 @@ namespace Bit.iOS.Autofill { await ASHelpers.ReplaceAllIdentities(); } + + _accountSwitchingOverlayHelper = new AccountSwitchingOverlayHelper(); + AccountSwitchingBarButton.Image = await _accountSwitchingOverlayHelper.CreateAvatarImageAsync(); + + _accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); + } + + partial void AccountSwitchingBarButton_Activated(UIBarButtonItem sender) + { + _accountSwitchingOverlayHelper.OnToolbarItemActivated(_accountSwitchingOverlayView, OverlayView); } partial void CancelBarButton_Activated(UIBarButtonItem sender) @@ -88,6 +113,35 @@ namespace Bit.iOS.Autofill } } + private void SubscribeSyncCompleted() + { + _broadcasterService.Value.Subscribe(nameof(LoginListViewController), message => + { + if (message.Command == "syncCompleted" && _alreadyLoadItemsOnce) + { + DispatchQueue.MainQueue.DispatchAsync(async () => + { + try + { + await ((TableSource)TableView.Source).LoadItemsAsync(); + TableView.ReloadData(); + } + catch (Exception ex) + { + _logger.Value.Exception(ex); + } + }); + } + }); + } + + public override void ViewDidUnload() + { + base.ViewDidUnload(); + + _broadcasterService.Value.Unsubscribe(nameof(LoginListViewController)); + } + public void DismissModal() { DismissViewController(true, async () => @@ -99,13 +153,11 @@ namespace Bit.iOS.Autofill public class TableSource : ExtensionTableSource { - private Context _context; private LoginListViewController _controller; public TableSource(LoginListViewController controller) : base(controller.Context, controller) { - _context = controller.Context; _controller = controller; } diff --git a/src/iOS.Autofill/LoginListViewController.designer.cs b/src/iOS.Autofill/LoginListViewController.designer.cs index baaf3e88e..8bdd8059c 100644 --- a/src/iOS.Autofill/LoginListViewController.designer.cs +++ b/src/iOS.Autofill/LoginListViewController.designer.cs @@ -1,59 +1,89 @@ // WARNING // -// This file has been generated automatically by Visual Studio from the outlets and -// actions declared in your storyboard file. -// Manual changes to this file will not be maintained. +// This file has been generated automatically by Visual Studio to store outlets and +// actions made in the UI designer. If it is removed, they will be lost. +// Manual changes to this file may not be handled correctly. // using Foundation; -using System; using System.CodeDom.Compiler; -using UIKit; namespace Bit.iOS.Autofill { - [Register ("LoginListViewController")] - partial class LoginListViewController - { - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UIBarButtonItem AddBarButton { get; set; } + [Register ("LoginListViewController")] + partial class LoginListViewController + { + [Outlet] + UIKit.UIBarButtonItem AccountSwitchingBarButton { get; set; } - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UIBarButtonItem CancelBarButton { get; set; } + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UIBarButtonItem AddBarButton { get; set; } - [Outlet] - [GeneratedCode ("iOS Designer", "1.0")] - UIKit.UINavigationItem NavItem { get; set; } + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UIBarButtonItem CancelBarButton { get; set; } - [Action ("AddBarButton_Activated:")] - [GeneratedCode ("iOS Designer", "1.0")] - partial void AddBarButton_Activated (UIKit.UIBarButtonItem sender); + [Outlet] + UIKit.UIView MainView { get; set; } - [Action ("CancelBarButton_Activated:")] - [GeneratedCode ("iOS Designer", "1.0")] - partial void CancelBarButton_Activated (UIKit.UIBarButtonItem sender); + [Outlet] + [GeneratedCode ("iOS Designer", "1.0")] + UIKit.UINavigationItem NavItem { get; set; } - [Action ("SearchBarButton_Activated:")] - [GeneratedCode ("iOS Designer", "1.0")] - partial void SearchBarButton_Activated (UIKit.UIBarButtonItem sender); + [Outlet] + UIKit.UIView OverlayView { get; set; } - void ReleaseDesignerOutlets () - { - if (AddBarButton != null) { - AddBarButton.Dispose (); - AddBarButton = null; - } + [Outlet] + UIKit.UITableView TableView { get; set; } - if (CancelBarButton != null) { - CancelBarButton.Dispose (); - CancelBarButton = null; - } + [Action ("AccountSwitchingBarButton_Activated:")] + partial void AccountSwitchingBarButton_Activated (UIKit.UIBarButtonItem sender); - if (NavItem != null) { - NavItem.Dispose (); - NavItem = null; - } - } - } -} \ No newline at end of file + [Action ("AddBarButton_Activated:")] + partial void AddBarButton_Activated (UIKit.UIBarButtonItem sender); + + [Action ("CancelBarButton_Activated:")] + partial void CancelBarButton_Activated (UIKit.UIBarButtonItem sender); + + [Action ("SearchBarButton_Activated:")] + partial void SearchBarButton_Activated (UIKit.UIBarButtonItem sender); + + void ReleaseDesignerOutlets () + { + if (AddBarButton != null) { + AddBarButton.Dispose (); + AddBarButton = null; + } + + if (CancelBarButton != null) { + CancelBarButton.Dispose (); + CancelBarButton = null; + } + + if (MainView != null) { + MainView.Dispose (); + MainView = null; + } + + if (NavItem != null) { + NavItem.Dispose (); + NavItem = null; + } + + if (OverlayView != null) { + OverlayView.Dispose (); + OverlayView = null; + } + + if (TableView != null) { + TableView.Dispose (); + TableView = null; + } + + if (AccountSwitchingBarButton != null) { + AccountSwitchingBarButton.Dispose (); + AccountSwitchingBarButton = null; + } + } + } +} diff --git a/src/iOS.Autofill/MainInterface.storyboard b/src/iOS.Autofill/MainInterface.storyboard index a5e219e6d..c2da85eff 100644 --- a/src/iOS.Autofill/MainInterface.storyboard +++ b/src/iOS.Autofill/MainInterface.storyboard @@ -1,7 +1,11 @@ - - + + + - + + + + @@ -9,30 +13,27 @@ - - - - - + - + + - + + - @@ -45,7 +46,7 @@ - + @@ -67,7 +68,7 @@ - + @@ -87,7 +88,7 @@ - + @@ -125,50 +126,79 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + @@ -186,13 +216,17 @@ + + + + - + @@ -202,7 +236,7 @@ - + @@ -222,37 +256,34 @@ - - - - - + - + + - + - - - - - + + + + + @@ -287,7 +318,7 @@ - + @@ -301,15 +332,11 @@ - - - - - + @@ -318,88 +345,139 @@ - + - + - + - - - + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + - - - - + + + + + + - - + + - + - - - - - + + - - - - - + + + + + - + @@ -429,7 +507,7 @@ - + @@ -450,7 +528,7 @@ - + @@ -463,10 +541,10 @@ - + - + @@ -506,7 +584,7 @@ - + @@ -522,8 +600,19 @@ + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/iOS.Autofill/Utilities/AutofillHelpers.cs b/src/iOS.Autofill/Utilities/AutofillHelpers.cs index 2f274b913..a2da552b4 100644 --- a/src/iOS.Autofill/Utilities/AutofillHelpers.cs +++ b/src/iOS.Autofill/Utilities/AutofillHelpers.cs @@ -15,7 +15,7 @@ namespace Bit.iOS.Autofill.Utilities { public async static Task TableRowSelectedAsync(UITableView tableView, NSIndexPath indexPath, ExtensionTableSource tableSource, CredentialProviderViewController cpViewController, - UITableViewController controller, IPasswordRepromptService passwordRepromptService, + UIViewController controller, IPasswordRepromptService passwordRepromptService, string loginAddSegue) { tableView.DeselectRow(indexPath, true); diff --git a/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs b/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs new file mode 100644 index 000000000..2dc1744c1 --- /dev/null +++ b/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs @@ -0,0 +1,509 @@ +using System; +using UIKit; +using Foundation; +using Bit.iOS.Core.Views; +using Bit.App.Resources; +using Bit.iOS.Core.Utilities; +using Bit.App.Abstractions; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using System.Threading.Tasks; +using Bit.App.Utilities; +using Bit.Core.Models.Domain; +using Bit.Core.Enums; +using Bit.App.Pages; +using Bit.App.Models; +using Xamarin.Forms; +using Bit.Core; + +namespace Bit.iOS.Core.Controllers +{ + public abstract class BaseLockPasswordViewController : ExtendedUIViewController + { + private IVaultTimeoutService _vaultTimeoutService; + private ICryptoService _cryptoService; + private IDeviceActionService _deviceActionService; + private IStateService _stateService; + private IStorageService _secureStorageService; + private IPlatformUtilsService _platformUtilsService; + private IBiometricService _biometricService; + private IKeyConnectorService _keyConnectorService; + private bool _isPinProtected; + private bool _isPinProtectedWithKey; + private bool _pinLock; + private bool _biometricLock; + private bool _biometricIntegrityValid = true; + private bool _passwordReprompt = false; + private bool _usesKeyConnector; + private bool _biometricUnlockOnly = false; + + protected bool autofillExtension = false; + + public BaseLockPasswordViewController(IntPtr handle) + : base(handle) + { } + + public abstract UINavigationItem BaseNavItem { get; } + public abstract UIBarButtonItem BaseCancelButton { get; } + public abstract UIBarButtonItem BaseSubmitButton { get; } + public abstract Action Success { get; } + public abstract Action Cancel { get; } + + public FormEntryTableViewCell MasterPasswordCell { get; set; } = new FormEntryTableViewCell( + AppResources.MasterPassword, useButton: true); + + public string BiometricIntegrityKey { get; set; } + + public UITableViewCell BiometricCell + { + get + { + var cell = new UITableViewCell(); + cell.BackgroundColor = ThemeHelpers.BackgroundColor; + if (_biometricIntegrityValid) + { + var biometricButtonText = _deviceActionService.SupportsFaceBiometric() ? + AppResources.UseFaceIDToUnlock : AppResources.UseFingerprintToUnlock; + cell.TextLabel.TextColor = ThemeHelpers.PrimaryColor; + cell.TextLabel.Text = biometricButtonText; + } + else + { + cell.TextLabel.TextColor = ThemeHelpers.DangerColor; + cell.TextLabel.Font = ThemeHelpers.GetDangerFont(); + cell.TextLabel.Lines = 0; + cell.TextLabel.LineBreakMode = UILineBreakMode.WordWrap; + cell.TextLabel.Text = AppResources.BiometricInvalidatedExtension; + } + return cell; + } + } + + public abstract UITableView TableView { get; } + + public override async void ViewDidLoad() + { + _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); + _cryptoService = ServiceContainer.Resolve("cryptoService"); + _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _stateService = ServiceContainer.Resolve("stateService"); + _secureStorageService = ServiceContainer.Resolve("secureStorageService"); + _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); + _biometricService = ServiceContainer.Resolve("biometricService"); + _keyConnectorService = ServiceContainer.Resolve("keyConnectorService"); + + // We re-use the lock screen for autofill extension to verify master password + // when trying to access protected items. + if (autofillExtension && await _stateService.GetPasswordRepromptAutofillAsync()) + { + _passwordReprompt = true; + _isPinProtected = false; + _isPinProtectedWithKey = false; + _pinLock = false; + _biometricLock = false; + } + else + { + (_isPinProtected, _isPinProtectedWithKey) = await _vaultTimeoutService.IsPinLockSetAsync(); + _pinLock = (_isPinProtected && await _stateService.GetPinProtectedKeyAsync() != null) || + _isPinProtectedWithKey; + _biometricLock = await _vaultTimeoutService.IsBiometricLockSetAsync() && + await _cryptoService.HasKeyAsync(); + _biometricIntegrityValid = await _biometricService.ValidateIntegrityAsync(BiometricIntegrityKey); + _usesKeyConnector = await _keyConnectorService.GetUsesKeyConnector(); + _biometricUnlockOnly = _usesKeyConnector && _biometricLock && !_pinLock; + } + + if (_pinLock) + { + BaseNavItem.Title = AppResources.VerifyPIN; + } + else if (_usesKeyConnector) + { + BaseNavItem.Title = AppResources.UnlockVault; + } + else + { + BaseNavItem.Title = AppResources.VerifyMasterPassword; + } + + BaseCancelButton.Title = AppResources.Cancel; + + if (_biometricUnlockOnly) + { + BaseSubmitButton.Title = null; + BaseSubmitButton.Enabled = false; + } + else + { + BaseSubmitButton.Title = AppResources.Submit; + } + + var descriptor = UIFontDescriptor.PreferredBody; + + if (!_biometricUnlockOnly) + { + MasterPasswordCell.Label.Text = _pinLock ? AppResources.PIN : AppResources.MasterPassword; + MasterPasswordCell.TextField.SecureTextEntry = true; + MasterPasswordCell.TextField.ReturnKeyType = UIReturnKeyType.Go; + MasterPasswordCell.TextField.ShouldReturn += (UITextField tf) => + { + CheckPasswordAsync().GetAwaiter().GetResult(); + return true; + }; + if (_pinLock) + { + MasterPasswordCell.TextField.KeyboardType = UIKeyboardType.NumberPad; + } + MasterPasswordCell.Button.TitleLabel.Font = UIFont.FromName("bwi-font", 28f); + MasterPasswordCell.Button.SetTitle(BitwardenIcons.Eye, UIControlState.Normal); + MasterPasswordCell.Button.TouchUpInside += (sender, e) => + { + MasterPasswordCell.TextField.SecureTextEntry = !MasterPasswordCell.TextField.SecureTextEntry; + MasterPasswordCell.Button.SetTitle(MasterPasswordCell.TextField.SecureTextEntry ? BitwardenIcons.Eye : BitwardenIcons.EyeSlash, UIControlState.Normal); + }; + } + + if (TableView != null) + { + TableView.BackgroundColor = ThemeHelpers.BackgroundColor; + TableView.SeparatorColor = ThemeHelpers.SeparatorColor; + } + + TableView.RowHeight = UITableView.AutomaticDimension; + TableView.EstimatedRowHeight = 70; + TableView.Source = new TableSource(this); + TableView.AllowsSelection = true; + + base.ViewDidLoad(); + + if (_biometricLock) + { + if (!_biometricIntegrityValid) + { + return; + } + var tasks = Task.Run(async () => + { + await Task.Delay(500); + NSRunLoop.Main.BeginInvokeOnMainThread(async () => await PromptBiometricAsync()); + }); + } + } + + public override async void ViewDidAppear(bool animated) + { + base.ViewDidAppear(animated); + + // Users with key connector and without biometric or pin has no MP to unlock with + if (_usesKeyConnector) + { + if (!(_pinLock || _biometricLock) || + (_biometricLock && !_biometricIntegrityValid)) + { + PromptSSO(); + } + } + else if (!_biometricLock || !_biometricIntegrityValid) + { + MasterPasswordCell.TextField.BecomeFirstResponder(); + } + } + + protected async Task CheckPasswordAsync() + { + if (string.IsNullOrWhiteSpace(MasterPasswordCell.TextField.Text)) + { + var alert = Dialogs.CreateAlert(AppResources.AnErrorHasOccurred, + string.Format(AppResources.ValidationFieldRequired, + _pinLock ? AppResources.PIN : AppResources.MasterPassword), + AppResources.Ok); + PresentViewController(alert, true, null); + return; + } + + var email = await _stateService.GetEmailAsync(); + var kdf = await _stateService.GetKdfTypeAsync(); + var kdfIterations = await _stateService.GetKdfIterationsAsync(); + var inputtedValue = MasterPasswordCell.TextField.Text; + + if (_pinLock) + { + var failed = true; + try + { + if (_isPinProtected) + { + var key = await _cryptoService.MakeKeyFromPinAsync(inputtedValue, email, + kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256), kdfIterations.GetValueOrDefault(5000), + await _stateService.GetPinProtectedKeyAsync()); + var encKey = await _cryptoService.GetEncKeyAsync(key); + var protectedPin = await _stateService.GetProtectedPinAsync(); + var decPin = await _cryptoService.DecryptToUtf8Async(new EncString(protectedPin), encKey); + failed = decPin != inputtedValue; + if (!failed) + { + await AppHelpers.ResetInvalidUnlockAttemptsAsync(); + await SetKeyAndContinueAsync(key); + } + } + else + { + var key2 = await _cryptoService.MakeKeyFromPinAsync(inputtedValue, email, + kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256), kdfIterations.GetValueOrDefault(5000)); + failed = false; + await AppHelpers.ResetInvalidUnlockAttemptsAsync(); + await SetKeyAndContinueAsync(key2); + } + } + catch + { + failed = true; + } + if (failed) + { + var invalidUnlockAttempts = await AppHelpers.IncrementInvalidUnlockAttemptsAsync(); + if (invalidUnlockAttempts >= 5) + { + await LogOutAsync(); + return; + } + InvalidValue(); + } + } + else + { + var key2 = await _cryptoService.MakeKeyAsync(inputtedValue, email, kdf, kdfIterations); + + var storedKeyHash = await _cryptoService.GetKeyHashAsync(); + if (storedKeyHash == null) + { + var oldKey = await _secureStorageService.GetAsync("oldKey"); + if (key2.KeyB64 == oldKey) + { + var localKeyHash = await _cryptoService.HashPasswordAsync(inputtedValue, key2, HashPurpose.LocalAuthorization); + await _secureStorageService.RemoveAsync("oldKey"); + await _cryptoService.SetKeyHashAsync(localKeyHash); + } + } + var passwordValid = await _cryptoService.CompareAndUpdateKeyHashAsync(inputtedValue, key2); + if (passwordValid) + { + if (_isPinProtected) + { + var protectedPin = await _stateService.GetProtectedPinAsync(); + var encKey = await _cryptoService.GetEncKeyAsync(key2); + var decPin = await _cryptoService.DecryptToUtf8Async(new EncString(protectedPin), encKey); + var pinKey = await _cryptoService.MakePinKeyAysnc(decPin, email, + kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256), kdfIterations.GetValueOrDefault(5000)); + await _stateService.SetPinProtectedKeyAsync(await _cryptoService.EncryptAsync(key2.Key, pinKey)); + } + await AppHelpers.ResetInvalidUnlockAttemptsAsync(); + await SetKeyAndContinueAsync(key2, true); + } + else + { + var invalidUnlockAttempts = await AppHelpers.IncrementInvalidUnlockAttemptsAsync(); + if (invalidUnlockAttempts >= 5) + { + await LogOutAsync(); + return; + } + InvalidValue(); + } + } + } + + public async Task PromptBiometricAsync() + { + if (!_biometricLock || !_biometricIntegrityValid) + { + return; + } + var success = await _platformUtilsService.AuthenticateBiometricAsync(null, + _pinLock ? AppResources.PIN : AppResources.MasterPassword, + () => MasterPasswordCell.TextField.BecomeFirstResponder()); + await _stateService.SetBiometricLockedAsync(!success); + if (success) + { + DoContinue(); + } + } + + public void PromptSSO() + { + var loginPage = new LoginSsoPage(); + var app = new App.App(new AppOptions { IosExtension = true }); + ThemeManager.SetTheme(app.Resources); + ThemeManager.ApplyResourcesToPage(loginPage); + if (loginPage.BindingContext is LoginSsoPageViewModel vm) + { + vm.SsoAuthSuccessAction = () => DoContinue(); + vm.CloseAction = Cancel; + } + + var navigationPage = new NavigationPage(loginPage); + var loginController = navigationPage.CreateViewController(); + loginController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; + PresentViewController(loginController, true, null); + } + + private async Task SetKeyAndContinueAsync(SymmetricCryptoKey key, bool masterPassword = false) + { + var hasKey = await _cryptoService.HasKeyAsync(); + if (!hasKey) + { + await _cryptoService.SetKeyAsync(key); + } + DoContinue(masterPassword); + } + + private async void DoContinue(bool masterPassword = false) + { + if (masterPassword) + { + await _stateService.SetPasswordVerifiedAutofillAsync(true); + } + await EnableBiometricsIfNeeded(); + await _stateService.SetBiometricLockedAsync(false); + MasterPasswordCell.TextField.ResignFirstResponder(); + Success(); + } + + private async Task EnableBiometricsIfNeeded() + { + // Re-enable biometrics if initial use + if (_biometricLock & !_biometricIntegrityValid) + { + await _biometricService.SetupBiometricAsync(BiometricIntegrityKey); + } + } + + private void InvalidValue() + { + var alert = Dialogs.CreateAlert(AppResources.AnErrorHasOccurred, + string.Format(null, _pinLock ? AppResources.PIN : AppResources.InvalidMasterPassword), + AppResources.Ok, (a) => + { + + MasterPasswordCell.TextField.Text = string.Empty; + MasterPasswordCell.TextField.BecomeFirstResponder(); + }); + PresentViewController(alert, true, null); + } + + private async Task LogOutAsync() + { + await AppHelpers.LogOutAsync(await _stateService.GetActiveUserIdAsync()); + var authService = ServiceContainer.Resolve("authService"); + authService.LogOut(() => + { + Cancel?.Invoke(); + }); + } + + public class TableSource : ExtendedUITableViewSource + { + private readonly BaseLockPasswordViewController _controller; + + public TableSource(BaseLockPasswordViewController controller) + { + _controller = controller; + } + + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + if (indexPath.Section == 0) + { + if (indexPath.Row == 0) + { + if (_controller._biometricUnlockOnly) + { + return _controller.BiometricCell; + } + else + { + return _controller.MasterPasswordCell; + } + } + } + else if (indexPath.Section == 1) + { + if (indexPath.Row == 0) + { + if (_controller._passwordReprompt) + { + var cell = new ExtendedUITableViewCell(); + cell.TextLabel.TextColor = ThemeHelpers.DangerColor; + cell.TextLabel.Font = ThemeHelpers.GetDangerFont(); + cell.TextLabel.Lines = 0; + cell.TextLabel.LineBreakMode = UILineBreakMode.WordWrap; + cell.TextLabel.Text = AppResources.PasswordConfirmationDesc; + return cell; + } + else if (!_controller._biometricUnlockOnly) + { + return _controller.BiometricCell; + } + } + } + return new ExtendedUITableViewCell(); + } + + public override nfloat GetHeightForRow(UITableView tableView, NSIndexPath indexPath) + { + return UITableView.AutomaticDimension; + } + + public override nint NumberOfSections(UITableView tableView) + { + return (!_controller._biometricUnlockOnly && _controller._biometricLock) || + _controller._passwordReprompt + ? 2 + : 1; + } + + public override nint RowsInSection(UITableView tableview, nint section) + { + if (section <= 1) + { + return 1; + } + return 0; + } + + public override nfloat GetHeightForHeader(UITableView tableView, nint section) + { + return section == 1 ? 0.00001f : UITableView.AutomaticDimension; + } + + public override string TitleForHeader(UITableView tableView, nint section) + { + return null; + } + + public override void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + tableView.DeselectRow(indexPath, true); + tableView.EndEditing(true); + if (indexPath.Row == 0 && + ((_controller._biometricUnlockOnly && indexPath.Section == 0) || + indexPath.Section == 1)) + { + var task = _controller.PromptBiometricAsync(); + return; + } + var cell = tableView.CellAt(indexPath); + if (cell == null) + { + return; + } + if (cell is ISelectable selectableCell) + { + selectableCell.Select(); + } + } + } + } +} + diff --git a/src/iOS.Core/Controllers/LockPasswordViewController.cs b/src/iOS.Core/Controllers/LockPasswordViewController.cs index 5f4bfcee4..fbce117ca 100644 --- a/src/iOS.Core/Controllers/LockPasswordViewController.cs +++ b/src/iOS.Core/Controllers/LockPasswordViewController.cs @@ -18,6 +18,8 @@ using Bit.Core; namespace Bit.iOS.Core.Controllers { + // TODO: Leaving this here until all inheritance is changed to use BaseLockPasswordViewController instead of UITableViewController + [Obsolete("Use BaseLockPasswordViewController instead")] public abstract class LockPasswordViewController : ExtendedUITableViewController { private IVaultTimeoutService _vaultTimeoutService; diff --git a/src/iOS.Core/Utilities/AccountSwitchingOverlayHelper.cs b/src/iOS.Core/Utilities/AccountSwitchingOverlayHelper.cs new file mode 100644 index 000000000..2f771677b --- /dev/null +++ b/src/iOS.Core/Utilities/AccountSwitchingOverlayHelper.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Bit.App.Controls; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +namespace Bit.iOS.Core.Utilities +{ + public class AccountSwitchingOverlayHelper + { + IStateService _stateService; + IMessagingService _messagingService; + ILogger _logger; + + public AccountSwitchingOverlayHelper() + { + _stateService = ServiceContainer.Resolve("stateService"); + _messagingService = ServiceContainer.Resolve("messagingService"); + _logger = ServiceContainer.Resolve("logger"); + } + + public async Task CreateAvatarImageAsync() + { + var avatarImageSource = new AvatarImageSource(await _stateService.GetNameAsync(), await _stateService.GetEmailAsync()); + var avatarUIImage = await avatarImageSource.GetNativeImageAsync(); + return avatarUIImage.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); + } + + public AccountSwitchingOverlayView CreateAccountSwitchingOverlayView(UIView containerView) + { + var overlay = new AccountSwitchingOverlayView() + { + LongPressAccountEnabled = false + }; + + var vm = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger); + overlay.BindingContext = vm; + + var renderer = Platform.CreateRenderer(overlay.Content); + renderer.SetElementSize(new Size(containerView.Frame.Size.Width, containerView.Frame.Size.Height)); + + var view = renderer.NativeView; + view.TranslatesAutoresizingMaskIntoConstraints = false; + + containerView.AddSubview(view); + containerView.AddConstraints(new NSLayoutConstraint[] + { + NSLayoutConstraint.Create(containerView, NSLayoutAttribute.Trailing, NSLayoutRelation.Equal, view, NSLayoutAttribute.Trailing, 1f, 0f), + NSLayoutConstraint.Create(containerView, NSLayoutAttribute.Leading, NSLayoutRelation.Equal, view, NSLayoutAttribute.Leading, 1f, 0f), + NSLayoutConstraint.Create(containerView, NSLayoutAttribute.Top, NSLayoutRelation.Equal, view, NSLayoutAttribute.Top, 1f, 0f), + NSLayoutConstraint.Create(containerView, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, view, NSLayoutAttribute.Bottom, 1f, 0f) + }); + containerView.Hidden = true; + + return overlay; + } + + public void OnToolbarItemActivated(AccountSwitchingOverlayView accountSwitchingOverlayView, UIView containerView) + { + var overlayVisible = accountSwitchingOverlayView.IsVisible; + accountSwitchingOverlayView.ToggleVisibililtyCommand.Execute(null); + containerView.Hidden = false; + containerView.UserInteractionEnabled = !overlayVisible; + containerView.Subviews[0].UserInteractionEnabled = !overlayVisible; + } + } +} diff --git a/src/iOS.Core/Utilities/ImageSourceExtensions.cs b/src/iOS.Core/Utilities/ImageSourceExtensions.cs new file mode 100644 index 000000000..25941a0bd --- /dev/null +++ b/src/iOS.Core/Utilities/ImageSourceExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Internals; +using Xamarin.Forms.Platform.iOS; + +namespace Bit.iOS.Core.Utilities +{ + public static class ImageSourceExtensions + { + /// + /// Gets the native image from the ImageSource. + /// Taken from https://github.com/xamarin/Xamarin.Forms/blob/02dee20dfa1365d0104758e534581d1fa5958990/Xamarin.Forms.Platform.iOS/Renderers/ImageElementManager.cs#L264 + /// + public static async Task GetNativeImageAsync(this ImageSource source, CancellationToken cancellationToken = default(CancellationToken)) + { + if (source == null || source.IsEmpty) + return null; + + var handler = Xamarin.Forms.Internals.Registrar.Registered.GetHandlerForObject(source); + if (handler == null) + return null; + + try + { + float scale = (float)UIScreen.MainScreen.Scale; + + return await handler.LoadImageAsync(source, scale: scale, cancelationToken: cancellationToken); + } + catch (OperationCanceledException) + { + Log.Warning("Image loading", "Image load cancelled"); + } + catch (Exception ex) + { + Log.Warning("Image loading", $"Image load failed: {ex}"); + } + + return null; + } + } +} diff --git a/src/iOS.Core/Utilities/iOSCoreHelpers.cs b/src/iOS.Core/Utilities/iOSCoreHelpers.cs index c1b7988bc..29cd91d67 100644 --- a/src/iOS.Core/Utilities/iOSCoreHelpers.cs +++ b/src/iOS.Core/Utilities/iOSCoreHelpers.cs @@ -7,6 +7,7 @@ using Bit.App.Pages; using Bit.App.Resources; using Bit.App.Services; using Bit.App.Utilities; +using Bit.App.Utilities.AccountManagement; using Bit.Core.Abstractions; using Bit.Core.Services; using Bit.Core.Utilities; @@ -172,6 +173,15 @@ namespace Bit.iOS.Core.Utilities ServiceContainer.Resolve("cryptoService")); ServiceContainer.Register("verificationActionsFlowHelper", verificationActionsFlowHelper); + var accountsManager = new AccountsManager( + ServiceContainer.Resolve("broadcasterService"), + ServiceContainer.Resolve("vaultTimeoutService"), + ServiceContainer.Resolve("secureStorageService"), + ServiceContainer.Resolve("stateService"), + ServiceContainer.Resolve("platformUtilsService"), + ServiceContainer.Resolve("authService")); + ServiceContainer.Register("accountsManager", accountsManager); + if (postBootstrapFunc != null) { await postBootstrapFunc.Invoke(); diff --git a/src/iOS.Core/Views/ExtensionTableSource.cs b/src/iOS.Core/Views/ExtensionTableSource.cs index 4bd856b1e..a548cd818 100644 --- a/src/iOS.Core/Views/ExtensionTableSource.cs +++ b/src/iOS.Core/Views/ExtensionTableSource.cs @@ -1,4 +1,10 @@ -using Bit.App.Resources; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Bit.App.Resources; using Bit.Core.Abstractions; using Bit.Core.Models.View; using Bit.Core.Utilities; @@ -6,12 +12,6 @@ using Bit.iOS.Core.Controllers; using Bit.iOS.Core.Models; using Bit.iOS.Core.Utilities; using Foundation; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using UIKit; namespace Bit.iOS.Core.Views @@ -122,7 +122,10 @@ namespace Bit.iOS.Core.Views public override void WillDisplay(UITableView tableView, UITableViewCell cell, NSIndexPath indexPath) { - if (Items == null || Items.Count() == 0 || cell == null) + if (Items == null + || !Items.Any() + || cell?.TextLabel == null + || cell.DetailTextLabel == null) { return; } diff --git a/src/iOS.Core/iOS.Core.csproj b/src/iOS.Core/iOS.Core.csproj index a0e0967da..4344ddb6d 100644 --- a/src/iOS.Core/iOS.Core.csproj +++ b/src/iOS.Core/iOS.Core.csproj @@ -195,6 +195,9 @@ + + +