From a18f74a72adc1821697887dee58dd729aad62c0e Mon Sep 17 00:00:00 2001 From: Federico Maccaroni <fedemkr@gmail.com> Date: Thu, 9 Mar 2023 11:16:48 -0300 Subject: [PATCH] [PM-1129] iOS 16 Third-Party 2FA OTP handling (#2409) * [EC-980] Added iOS otpauth handler (#2370) * EC-980 added Bitwarden as otpauth scheme handler * EC-980 Fix format * [EC-981] OTP handling - Set to selected cipher (#2404) * EC-981 Started adding OTP to existing cipher. Reused AutofillCiphersPage for the cipher selection and refactored it so that we have more code reuse * EC-981 Fix navigation on otp handling * EC-981 Fix formatting * EC-981 Added otp cipher selection callout and add close toolbar item when needed * PM-1131 implemented cipher creation from otp handling flow with otp key filled (#2407) * PM-1133 Updated empty states for search and cipher selection on otp flow (#2408) --- src/Android/MainActivity.cs | 8 +- src/App/Abstractions/IAccountsManager.cs | 1 + src/App/Abstractions/IDeepLinkContext.cs | 9 + src/App/App.xaml.cs | 39 +- src/App/Models/AppOptions.cs | 3 + .../Vault/AutofillCiphersPageViewModel.cs | 229 ++---- .../Pages/Vault/CipherAddEditPageViewModel.cs | 23 + ...hersPage.xaml => CipherSelectionPage.xaml} | 61 +- ...ge.xaml.cs => CipherSelectionPage.xaml.cs} | 72 +- .../Vault/CipherSelectionPageViewModel.cs | 167 ++++ src/App/Pages/Vault/CiphersPage.xaml | 22 +- src/App/Pages/Vault/CiphersPage.xaml.cs | 14 +- src/App/Pages/Vault/CiphersPageViewModel.cs | 81 +- .../Vault/OTPCipherSelectionPageViewModel.cs | 79 ++ src/App/Resources/AppResources.Designer.cs | 47 +- src/App/Resources/AppResources.resx | 12 + src/App/Services/DeepLinkContext.cs | 30 + .../AccountManagement/AccountsManager.cs | 12 +- src/App/Utilities/AppHelpers.cs | 6 +- src/App/Utilities/AppSetup.cs | 5 +- src/Core/Constants.cs | 1 + src/Core/Enums/NavigationTarget.cs | 3 +- src/Core/Services/TotpService.cs | 35 +- src/Core/Utilities/CoreHelpers.cs | 19 +- src/Core/Utilities/OtpData.cs | 94 +++ src/Core/Utilities/UriExtensions.cs | 18 + src/iOS/AppDelegate.cs | 4 +- src/iOS/Info.plist | 8 + .../Resources/Assets.xcassets/Contents.json | 6 + .../empty_items_state.imageset/Contents.json | 25 + .../Empty-items-state-dark.pdf | Bin 0 -> 1891 bytes .../Empty-items-state.pdf | Bin 0 -> 1875 bytes .../ic_warning.imageset/Contents.json | 770 ++++++++++-------- src/iOS/iOS.csproj | 4 + 34 files changed, 1277 insertions(+), 630 deletions(-) create mode 100644 src/App/Abstractions/IDeepLinkContext.cs rename src/App/Pages/Vault/{AutofillCiphersPage.xaml => CipherSelectionPage.xaml} (68%) rename src/App/Pages/Vault/{AutofillCiphersPage.xaml.cs => CipherSelectionPage.xaml.cs} (66%) create mode 100644 src/App/Pages/Vault/CipherSelectionPageViewModel.cs create mode 100644 src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs create mode 100644 src/App/Services/DeepLinkContext.cs create mode 100644 src/Core/Utilities/OtpData.cs create mode 100644 src/Core/Utilities/UriExtensions.cs create mode 100644 src/iOS/Resources/Assets.xcassets/Contents.json create mode 100644 src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json create mode 100644 src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf create mode 100644 src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index 2e25bc1cc..f4fc640d0 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -170,7 +170,7 @@ namespace Bit.Droid { if (intent?.GetStringExtra("uri") is string uri) { - _messagingService.Send("popAllAndGoToAutofillCiphers"); + _messagingService.Send(App.App.POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE); if (_appOptions != null) { _appOptions.Uri = uri; @@ -178,7 +178,7 @@ namespace Bit.Droid } else if (intent.GetBooleanExtra("generatorTile", false)) { - _messagingService.Send("popAllAndGoToTabGenerator"); + _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE); if (_appOptions != null) { _appOptions.GeneratorTile = true; @@ -186,7 +186,7 @@ namespace Bit.Droid } else if (intent.GetBooleanExtra("myVaultTile", false)) { - _messagingService.Send("popAllAndGoToTabMyVault"); + _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE); if (_appOptions != null) { _appOptions.MyVaultTile = true; @@ -198,7 +198,7 @@ namespace Bit.Droid { _appOptions.CreateSend = GetCreateSendRequest(intent); } - _messagingService.Send("popAllAndGoToTabSend"); + _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE); } else { diff --git a/src/App/Abstractions/IAccountsManager.cs b/src/App/Abstractions/IAccountsManager.cs index 532b75958..ab78c6ca4 100644 --- a/src/App/Abstractions/IAccountsManager.cs +++ b/src/App/Abstractions/IAccountsManager.cs @@ -8,6 +8,7 @@ namespace Bit.App.Abstractions { void Init(Func<AppOptions> getOptionsFunc, IAccountsManagerHost accountsManagerHost); Task NavigateOnAccountChangeAsync(bool? isAuthed = null); + Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction); Task LogOutAsync(string userId, bool userInitiated, bool expired); Task PromptToSwitchToExistingAccountAsync(string userId); } diff --git a/src/App/Abstractions/IDeepLinkContext.cs b/src/App/Abstractions/IDeepLinkContext.cs new file mode 100644 index 000000000..345596d50 --- /dev/null +++ b/src/App/Abstractions/IDeepLinkContext.cs @@ -0,0 +1,9 @@ +using System; + +namespace Bit.App.Abstractions +{ + public interface IDeepLinkContext + { + bool OnNewUri(Uri uri); + } +} diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 095d5778a..3cbc4ff52 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -23,6 +23,11 @@ namespace Bit.App { public partial class App : Application, IAccountsManagerHost { + public const string POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE = "popAllAndGoToTabGenerator"; + public const string POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE = "popAllAndGoToTabMyVault"; + public const string POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE = "popAllAndGoToTabSend"; + public const string POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE = "popAllAndGoToAutofillCiphers"; + private readonly IBroadcasterService _broadcasterService; private readonly IMessagingService _messagingService; private readonly IStateService _stateService; @@ -103,12 +108,18 @@ namespace Bit.App await Task.Delay(1000); await _accountsManager.NavigateOnAccountChangeAsync(); } - else if (message.Command == "popAllAndGoToTabGenerator" || - message.Command == "popAllAndGoToTabMyVault" || - message.Command == "popAllAndGoToTabSend" || - message.Command == "popAllAndGoToAutofillCiphers") + else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE || + message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE || + message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE || + message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE || + message.Command == DeepLinkContext.NEW_OTP_MESSAGE) { - Device.BeginInvokeOnMainThread(async () => + if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE) + { + Options.OtpData = new OtpData((string)message.Data); + } + + await Device.InvokeOnMainThreadAsync(async () => { if (Current.MainPage is TabsPage tabsPage) { @@ -116,24 +127,29 @@ namespace Bit.App { await tabsPage.Navigation.PopModalAsync(false); } - if (message.Command == "popAllAndGoToAutofillCiphers") + if (message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE) { - Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); + Current.MainPage = new NavigationPage(new CipherSelectionPage(Options)); } - else if (message.Command == "popAllAndGoToTabMyVault") + else if (message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE) { Options.MyVaultTile = false; tabsPage.ResetToVaultPage(); } - else if (message.Command == "popAllAndGoToTabGenerator") + else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE) { Options.GeneratorTile = false; tabsPage.ResetToGeneratorPage(); } - else if (message.Command == "popAllAndGoToTabSend") + else if (message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE) { tabsPage.ResetToSendPage(); } + else if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE) + { + tabsPage.ResetToVaultPage(); + await tabsPage.Navigation.PushModalAsync(new NavigationPage(new CipherSelectionPage(Options))); + } } }); } @@ -494,7 +510,8 @@ namespace Bit.App Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: Options)); break; case NavigationTarget.AutofillCiphers: - Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); + case NavigationTarget.OtpCipherSelection: + Current.MainPage = new NavigationPage(new CipherSelectionPage(Options)); break; case NavigationTarget.SendAddEdit: Current.MainPage = new NavigationPage(new SendAddEditPage(Options)); diff --git a/src/App/Models/AppOptions.cs b/src/App/Models/AppOptions.cs index 0a251d7cd..58fe79d49 100644 --- a/src/App/Models/AppOptions.cs +++ b/src/App/Models/AppOptions.cs @@ -1,5 +1,6 @@ using System; using Bit.Core.Enums; +using Bit.Core.Utilities; namespace Bit.App.Models { @@ -23,6 +24,7 @@ namespace Bit.App.Models public Tuple<SendType, string, byte[], string> CreateSend { get; set; } public bool CopyInsteadOfShareAfterSaving { get; set; } public bool HideAccountSwitcher { get; set; } + public OtpData? OtpData { get; set; } public void SetAllFrom(AppOptions o) { @@ -48,6 +50,7 @@ namespace Bit.App.Models CreateSend = o.CreateSend; CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving; HideAccountSwitcher = o.HideAccountSwitcher; + OtpData = o.OtpData; } } } diff --git a/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs b/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs index 525f7dea1..aade36dd6 100644 --- a/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs +++ b/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs @@ -1,91 +1,29 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Bit.App.Abstractions; -using Bit.App.Controls; using Bit.App.Models; using Bit.App.Resources; using Bit.App.Utilities; using Bit.Core; -using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.View; using Bit.Core.Utilities; -using Xamarin.CommunityToolkit.ObjectModel; using Xamarin.Forms; namespace Bit.App.Pages { - public class AutofillCiphersPageViewModel : BaseViewModel + public class AutofillCiphersPageViewModel : CipherSelectionPageViewModel { - private readonly IPlatformUtilsService _platformUtilsService; - private readonly IDeviceActionService _deviceActionService; - private readonly IAutofillHandler _autofillHandler; - private readonly ICipherService _cipherService; - private readonly IStateService _stateService; - private readonly IPasswordRepromptService _passwordRepromptService; - private readonly IMessagingService _messagingService; - private readonly ILogger _logger; + private CipherType? _fillType; - private bool _showNoData; - private bool _showList; - private string _noDataText; - private bool _websiteIconsEnabled; - - public AutofillCiphersPageViewModel() - { - _platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"); - _cipherService = ServiceContainer.Resolve<ICipherService>("cipherService"); - _deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService"); - _autofillHandler = ServiceContainer.Resolve<IAutofillHandler>(); - _stateService = ServiceContainer.Resolve<IStateService>("stateService"); - _passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService"); - _messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService"); - _logger = ServiceContainer.Resolve<ILogger>("logger"); - - GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>(); - CipherOptionsCommand = new Command<CipherView>(CipherOptionsAsync); - - AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) - { - AllowAddAccountRow = false - }; - } - - public string Name { get; set; } public string Uri { get; set; } - public Command CipherOptionsCommand { get; set; } - public bool LoadedOnce { get; set; } - public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; } - public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } - public bool ShowNoData - { - get => _showNoData; - set => SetProperty(ref _showNoData, value); - } - - public bool ShowList - { - get => _showList; - set => SetProperty(ref _showList, value); - } - - public string NoDataText - { - get => _noDataText; - set => SetProperty(ref _noDataText, value); - } - public bool WebsiteIconsEnabled - { - get => _websiteIconsEnabled; - set => SetProperty(ref _websiteIconsEnabled, value); - } - - public void Init(AppOptions appOptions) + public override void Init(AppOptions appOptions) { Uri = appOptions?.Uri; + _fillType = appOptions.FillType; + string name = null; if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false) { @@ -104,14 +42,11 @@ namespace Bit.App.Pages NoDataText = string.Format(AppResources.NoItemsForUri, Name ?? "--"); } - public async Task LoadAsync() + protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync() { - LoadedOnce = true; - ShowList = false; - ShowNoData = false; - WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault(); var groupedItems = new List<GroupingsPageListGroup>(); var ciphers = await _cipherService.GetAllDecryptedByUrlAsync(Uri, null); + var matching = ciphers.Item1?.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(); var hasMatching = matching?.Any() ?? false; if (matching?.Any() ?? false) @@ -119,6 +54,7 @@ namespace Bit.App.Pages groupedItems.Add( new GroupingsPageListGroup(matching, AppResources.MatchingItems, matching.Count, false, true)); } + var fuzzy = ciphers.Item2?.Select(c => new GroupingsPageListItem { Cipher = c, FuzzyAutofill = true }).ToList(); if (fuzzy?.Any() ?? false) @@ -128,123 +64,88 @@ namespace Bit.App.Pages !hasMatching)); } - // TODO: refactor this - if (Device.RuntimePlatform == Device.Android - || - GroupedItems.Any()) - { - var items = new List<IGroupingsPageListItem>(); - foreach (var itemGroup in groupedItems) - { - items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount)); - items.AddRange(itemGroup); - } - - GroupedItems.ReplaceRange(items); - } - else - { - // HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list - var first = true; - var items = new List<IGroupingsPageListItem>(); - foreach (var itemGroup in groupedItems) - { - if (!first) - { - items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount)); - } - else - { - first = false; - } - items.AddRange(itemGroup); - } - - if (groupedItems.Any()) - { - GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) }); - GroupedItems.AddRange(items); - } - else - { - GroupedItems.Clear(); - } - } - ShowList = groupedItems.Any(); - ShowNoData = !ShowList; + return groupedItems; } - public async Task SelectCipherAsync(CipherView cipher, bool fuzzy) + protected override async Task SelectCipherAsync(IGroupingsPageListItem item) { - if (cipher == null) + if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null) { return; } + + var cipher = listItem.Cipher; + if (_deviceActionService.SystemMajorVersion() < 21) { await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService); + return; } - else + + if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync()) { - if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync()) + return; + } + var autofillResponse = AppResources.Yes; + if (listItem.FuzzyAutofill) + { + var options = new List<string> { AppResources.Yes }; + if (cipher.Type == CipherType.Login && + Xamarin.Essentials.Connectivity.NetworkAccess != Xamarin.Essentials.NetworkAccess.None) { - return; + options.Add(AppResources.YesAndSave); } - var autofillResponse = AppResources.Yes; - if (fuzzy) + autofillResponse = await _deviceActionService.DisplayAlertAsync(null, + string.Format(AppResources.BitwardenAutofillServiceMatchConfirm, Name), AppResources.No, + options.ToArray()); + } + if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login) + { + var uris = cipher.Login?.Uris?.ToList(); + if (uris == null) { - var options = new List<string> { AppResources.Yes }; - if (cipher.Type == CipherType.Login && - Xamarin.Essentials.Connectivity.NetworkAccess != Xamarin.Essentials.NetworkAccess.None) - { - options.Add(AppResources.YesAndSave); - } - autofillResponse = await _deviceActionService.DisplayAlertAsync(null, - string.Format(AppResources.BitwardenAutofillServiceMatchConfirm, Name), AppResources.No, - options.ToArray()); + uris = new List<LoginUriView>(); } - if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login) + uris.Add(new LoginUriView { - var uris = cipher.Login?.Uris?.ToList(); - if (uris == null) + Uri = Uri, + Match = null + }); + cipher.Login.Uris = uris; + try + { + await _deviceActionService.ShowLoadingAsync(AppResources.Saving); + await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher)); + await _deviceActionService.HideLoadingAsync(); + } + catch (ApiException e) + { + await _deviceActionService.HideLoadingAsync(); + if (e?.Error != null) { - uris = new List<LoginUriView>(); - } - uris.Add(new LoginUriView - { - Uri = Uri, - Match = null - }); - cipher.Login.Uris = uris; - try - { - await _deviceActionService.ShowLoadingAsync(AppResources.Saving); - await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher)); - await _deviceActionService.HideLoadingAsync(); - } - catch (ApiException e) - { - await _deviceActionService.HideLoadingAsync(); - if (e?.Error != null) - { - await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), - AppResources.AnErrorHasOccurred); - } + await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(), + AppResources.AnErrorHasOccurred); } } - if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave) - { - _autofillHandler.Autofill(cipher); - } + } + if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave) + { + _autofillHandler.Autofill(cipher); } } - private async void CipherOptionsAsync(CipherView cipher) + protected override async Task AddCipherAsync() { - if ((Page as BaseContentPage).DoOnce()) + if (_fillType.HasValue && _fillType != CipherType.Login) { - await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService); + var pageForOther = new CipherAddEditPage(type: _fillType, fromAutofill: true); + await Page.Navigation.PushModalAsync(new NavigationPage(pageForOther)); + return; } + + var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: Uri, name: Name, + fromAutofill: true); + await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin)); } } } diff --git a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs index cbc3838e5..e0a6cc226 100644 --- a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs +++ b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Bit.App.Abstractions; using Bit.App.Lists.ItemViewModels.CustomFields; using Bit.App.Models; using Bit.App.Resources; @@ -30,6 +31,7 @@ namespace Bit.App.Pages private readonly IClipboardService _clipboardService; private readonly IAutofillHandler _autofillHandler; private readonly IWatchDeviceService _watchDeviceService; + private readonly IAccountsManager _accountsManager; private bool _showNotesSeparator; private bool _showPassword; @@ -44,6 +46,8 @@ namespace Bit.App.Pages private bool _hasCollections; private string _previousCipherId; private List<Core.Models.View.CollectionView> _writeableCollections; + private bool _fromOtp; + protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => new string[] { nameof(IsLogin), @@ -82,6 +86,8 @@ namespace Bit.App.Pages _clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService"); _autofillHandler = ServiceContainer.Resolve<IAutofillHandler>(); _watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>(); + _accountsManager = ServiceContainer.Resolve<IAccountsManager>(); + GeneratePasswordCommand = new Command(GeneratePassword); TogglePasswordCommand = new Command(TogglePassword); @@ -302,6 +308,7 @@ namespace Bit.App.Pages public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; public bool HasTotpValue => IsLogin && !string.IsNullOrEmpty(Cipher?.Login?.Totp); public string SetupTotpText => $"{BitwardenIcons.Camera} {AppResources.SetupTotp}"; + public void Init() { PageTitle = EditMode && !CloneMode ? AppResources.EditItem : AppResources.AddItem; @@ -309,6 +316,8 @@ namespace Bit.App.Pages public async Task<bool> LoadAsync(AppOptions appOptions = null) { + _fromOtp = appOptions?.OtpData != null; + var myEmail = await _stateService.GetEmailAsync(); OwnershipOptions.Add(new KeyValuePair<string, string>(myEmail, null)); var orgs = await _organizationService.GetAllAsync(); @@ -358,6 +367,10 @@ namespace Bit.App.Pages Cipher.OrganizationId = OrganizationId; } } + if (appOptions?.OtpData != null && Cipher.Type == CipherType.Login) + { + Cipher.Login.Totp = appOptions.OtpData.Value.Uri; + } } else { @@ -380,6 +393,7 @@ namespace Bit.App.Pages Cipher.Type = appOptions.SaveType.GetValueOrDefault(Cipher.Type); Cipher.Login.Username = appOptions.SaveUsername; Cipher.Login.Password = appOptions.SavePassword; + Cipher.Login.Totp = appOptions.OtpData?.Uri; Cipher.Card.Code = appOptions.SaveCardCode; if (int.TryParse(appOptions.SaveCardExpMonth, out int month) && month <= 12 && month >= 1) { @@ -424,6 +438,11 @@ namespace Bit.App.Pages { Fields.ResetWithRange(Cipher.Fields?.Select(f => _customFieldItemFactory.CreateCustomFieldItem(f, true, Cipher, null, null, FieldOptionsCommand))); } + + if (appOptions?.OtpData != null) + { + _platformUtilsService.ShowToast(null, AppResources.AuthenticatorKey, AppResources.AuthenticatorKeyAdded); + } } if (EditMode && _previousCipherId != CipherId) @@ -517,6 +536,10 @@ namespace Bit.App.Pages // Close and go back to app _autofillHandler.CloseAutofill(); } + else if (_fromOtp) + { + await _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null); + } else { if (CloneMode) diff --git a/src/App/Pages/Vault/AutofillCiphersPage.xaml b/src/App/Pages/Vault/CipherSelectionPage.xaml similarity index 68% rename from src/App/Pages/Vault/AutofillCiphersPage.xaml rename to src/App/Pages/Vault/CipherSelectionPage.xaml index e1cc992ff..979392e3b 100644 --- a/src/App/Pages/Vault/AutofillCiphersPage.xaml +++ b/src/App/Pages/Vault/CipherSelectionPage.xaml @@ -1,30 +1,26 @@ <?xml version="1.0" encoding="utf-8" ?> <pages:BaseContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" - x:Class="Bit.App.Pages.AutofillCiphersPage" + xmlns:xct="http://xamarin.com/schemas/2020/toolkit" + x:Class="Bit.App.Pages.CipherSelectionPage" xmlns:pages="clr-namespace:Bit.App.Pages" xmlns:u="clr-namespace:Bit.App.Utilities" xmlns:controls="clr-namespace:Bit.App.Controls" xmlns:effects="clr-namespace:Bit.App.Effects;assembly=BitwardenApp" - x:DataType="pages:AutofillCiphersPageViewModel" + x:DataType="pages:CipherSelectionPageViewModel" Title="{Binding PageTitle}" x:Name="_page"> - - <ContentPage.BindingContext> - <pages:AutofillCiphersPageViewModel /> - </ContentPage.BindingContext> - <ContentPage.ToolbarItems> <controls:ExtendedToolbarItem x:Name="_accountAvatar" IconImageSource="{Binding AvatarImageSource}" Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}" Order="Primary" - Priority="-1" + Priority="-2" UseOriginalImage="True" AutomationProperties.IsInAccessibleTree="True" AutomationProperties.Name="{u:I18n Account}" /> - <ToolbarItem Icon="search.png" Clicked="Search_Clicked" + <ToolbarItem IconImageSource="search.png" Clicked="Search_Clicked" AutomationProperties.IsInAccessibleTree="True" AutomationProperties.Name="{u:I18n Search}" /> </ContentPage.ToolbarItems> @@ -32,6 +28,21 @@ <ContentPage.Resources> <ResourceDictionary> <u:InverseBoolConverter x:Key="inverseBool" /> + <controls:SelectionChangedEventArgsConverter x:Key="SelectionChangedEventArgsConverter" /> + + <ToolbarItem + x:Name="_closeItem" + x:Key="_closeItem" + Text="{u:I18n Close}" + Clicked="CloseItem_Clicked" + Order="Primary" + Priority="-1" /> + <ToolbarItem x:Name="_addItem" x:Key="addItem" + IconImageSource="plus.png" + Command="{Binding AddCipherCommand}" + Order="Primary" + AutomationProperties.IsInAccessibleTree="True" + AutomationProperties.Name="{u:I18n AddItem}" /> <DataTemplate x:Key="cipherTemplate" x:DataType="pages:GroupingsPageListItem"> @@ -70,23 +81,44 @@ Padding="20, 0" Spacing="20" IsVisible="{Binding ShowNoData}"> + <Image + Source="empty_items_state" /> <Label Text="{Binding NoDataText}" HorizontalTextAlignment="Center"></Label> <Button Text="{u:I18n AddAnItem}" - Clicked="AddButton_Clicked"></Button> + Command="{Binding AddCipherCommand}" /> </StackLayout> + <Frame + IsVisible="{Binding ShowCallout}" + Padding="10" + Margin="20, 10" + HasShadow="False" + BackgroundColor="Transparent" + BorderColor="{DynamicResource PrimaryColor}"> + <Label + Text="{u:I18n AddTheKeyToAnExistingOrNewItem}" + StyleClass="text-muted, text-sm, text-bold" + HorizontalTextAlignment="Center" /> + </Frame> + <controls:ExtendedCollectionView IsVisible="{Binding ShowList}" ItemsSource="{Binding GroupedItems}" VerticalOptions="FillAndExpand" ItemTemplate="{StaticResource listItemDataTemplateSelector}" SelectionMode="Single" - SelectionChanged="RowSelected" StyleClass="list, list-platform" - ExtraDataForLogging="Autofill Ciphers Page" /> + ExtraDataForLogging="Autofill Ciphers Page"> + <controls:ExtendedCollectionView.Behaviors> + <xct:EventToCommandBehavior + EventName="SelectionChanged" + Command="{Binding SelectCipherCommand}" + EventArgsConverter="{StaticResource SelectionChangedEventArgsConverter}" /> + </controls:ExtendedCollectionView.Behaviors> + </controls:ExtendedCollectionView> </StackLayout> </ResourceDictionary> </ContentPage.Resources> @@ -104,9 +136,10 @@ <!-- Android FAB --> <Button x:Name="_fab" - Image="plus.png" - Clicked="AddButton_Clicked" + ImageSource="plus.png" + Command="{Binding AddCipherCommand}" Style="{StaticResource btn-fab}" + IsVisible="{OnPlatform iOS=false, Android=true}" AbsoluteLayout.LayoutFlags="PositionProportional" AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize"> <Button.Effects> diff --git a/src/App/Pages/Vault/AutofillCiphersPage.xaml.cs b/src/App/Pages/Vault/CipherSelectionPage.xaml.cs similarity index 66% rename from src/App/Pages/Vault/AutofillCiphersPage.xaml.cs rename to src/App/Pages/Vault/CipherSelectionPage.xaml.cs index 2d009a18e..9d08d7d8a 100644 --- a/src/App/Pages/Vault/AutofillCiphersPage.xaml.cs +++ b/src/App/Pages/Vault/CipherSelectionPage.xaml.cs @@ -1,7 +1,6 @@ using System; -using System.Linq; using System.Threading.Tasks; -using Bit.App.Controls; +using Bit.App.Abstractions; using Bit.App.Models; using Bit.App.Utilities; using Bit.Core.Abstractions; @@ -12,27 +11,46 @@ using Xamarin.Forms; namespace Bit.App.Pages { - public partial class AutofillCiphersPage : BaseContentPage + public partial class CipherSelectionPage : BaseContentPage { private readonly AppOptions _appOptions; private readonly IBroadcasterService _broadcasterService; private readonly ISyncService _syncService; private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly IAccountsManager _accountsManager; - private AutofillCiphersPageViewModel _vm; + private readonly CipherSelectionPageViewModel _vm; - public AutofillCiphersPage(AppOptions appOptions) + public CipherSelectionPage(AppOptions appOptions) { _appOptions = appOptions; + + if (appOptions?.OtpData is null) + { + BindingContext = new AutofillCiphersPageViewModel(); + } + else + { + BindingContext = new OTPCipherSelectionPageViewModel(); + } + InitializeComponent(); + + if (Device.RuntimePlatform == Device.iOS) + { + ToolbarItems.Add(_closeItem); + ToolbarItems.Add(_addItem); + } + SetActivityIndicator(_mainContent); - _vm = BindingContext as AutofillCiphersPageViewModel; + _vm = BindingContext as CipherSelectionPageViewModel; _vm.Page = this; _vm.Init(appOptions); _broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService"); _syncService = ServiceContainer.Resolve<ISyncService>("syncService"); _vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService"); + _accountsManager = ServiceContainer.Resolve<IAccountsManager>(); } protected async override void OnAppearing() @@ -51,10 +69,16 @@ namespace Bit.App.Pages return; } - _accountAvatar?.OnAppearing(); - _vm.AvatarImageSource = await GetAvatarImageSourceAsync(); + // TODO: There's currently an issue on iOS where the toolbar item is not getting updated + // as the others somehow. Removing this so at least we get the circle with ".." instead + // of a white circle + if (Device.RuntimePlatform != Device.iOS) + { + _accountAvatar?.OnAppearing(); + _vm.AvatarImageSource = await GetAvatarImageSourceAsync(); + } - _broadcasterService.Subscribe(nameof(AutofillCiphersPage), async (message) => + _broadcasterService.Subscribe(nameof(CipherSelectionPage), async (message) => { try { @@ -116,40 +140,44 @@ namespace Bit.App.Pages _accountAvatar?.OnDisappearing(); } - private async void RowSelected(object sender, SelectionChangedEventArgs e) + private void AddButton_Clicked(object sender, System.EventArgs e) { - ((ExtendedCollectionView)sender).SelectedItem = null; if (!DoOnce()) { return; } - if (e.CurrentSelection?.FirstOrDefault() is GroupingsPageListItem item && item.Cipher != null) + + if (_vm is AutofillCiphersPageViewModel autofillVM) { - await _vm.SelectCipherAsync(item.Cipher, item.FuzzyAutofill); + AddFromAutofill(autofillVM).FireAndForget(); } } - private async void AddButton_Clicked(object sender, System.EventArgs e) + private async Task AddFromAutofill(AutofillCiphersPageViewModel autofillVM) { - if (!DoOnce()) - { - return; - } if (_appOptions.FillType.HasValue && _appOptions.FillType != CipherType.Login) { var pageForOther = new CipherAddEditPage(type: _appOptions.FillType, fromAutofill: true); await Navigation.PushModalAsync(new NavigationPage(pageForOther)); return; } - var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: _vm.Uri, name: _vm.Name, + var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: autofillVM.Uri, name: _vm.Name, fromAutofill: true); await Navigation.PushModalAsync(new NavigationPage(pageForLogin)); } - private void Search_Clicked(object sender, System.EventArgs e) + private void Search_Clicked(object sender, EventArgs e) { - var page = new CiphersPage(null, autofillUrl: _vm.Uri); - Application.Current.MainPage = new NavigationPage(page); + var page = new CiphersPage(null, appOptions: _appOptions); + Navigation.PushModalAsync(new NavigationPage(page)).FireAndForget(); + } + + void CloseItem_Clicked(object sender, EventArgs e) + { + if (DoOnce()) + { + _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null).FireAndForget(); + } } } } diff --git a/src/App/Pages/Vault/CipherSelectionPageViewModel.cs b/src/App/Pages/Vault/CipherSelectionPageViewModel.cs new file mode 100644 index 000000000..1a0144a62 --- /dev/null +++ b/src/App/Pages/Vault/CipherSelectionPageViewModel.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using Bit.App.Abstractions; +using Bit.App.Controls; +using Bit.App.Models; +using Bit.App.Resources; +using Bit.App.Utilities; +using Bit.Core; +using Bit.Core.Abstractions; +using Bit.Core.Models.View; +using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public abstract class CipherSelectionPageViewModel : BaseViewModel + { + protected readonly IPlatformUtilsService _platformUtilsService; + protected readonly IDeviceActionService _deviceActionService; + protected readonly IAutofillHandler _autofillHandler; + protected readonly ICipherService _cipherService; + protected readonly IStateService _stateService; + protected readonly IPasswordRepromptService _passwordRepromptService; + protected readonly IMessagingService _messagingService; + protected readonly ILogger _logger; + + protected bool _showNoData; + protected bool _showList; + protected string _noDataText; + protected bool _websiteIconsEnabled; + + public CipherSelectionPageViewModel() + { + _platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>(); + _cipherService = ServiceContainer.Resolve<ICipherService>(); + _deviceActionService = ServiceContainer.Resolve<IDeviceActionService>(); + _autofillHandler = ServiceContainer.Resolve<IAutofillHandler>(); + _stateService = ServiceContainer.Resolve<IStateService>(); + _passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>(); + _messagingService = ServiceContainer.Resolve<IMessagingService>(); + _logger = ServiceContainer.Resolve<ILogger>(); + + GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>(); + CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService), + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + SelectCipherCommand = new AsyncCommand<IGroupingsPageListItem>(SelectCipherAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + AddCipherCommand = new AsyncCommand(AddCipherAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + + AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) + { + AllowAddAccountRow = false + }; + } + + public string Name { get; set; } + public bool LoadedOnce { get; set; } + public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; } + public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } + + public ICommand CipherOptionsCommand { get; set; } + public ICommand SelectCipherCommand { get; set; } + public ICommand AddCipherCommand { get; set; } + + public bool ShowNoData + { + get => _showNoData; + set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[] { nameof(ShowCallout) }); + } + + public bool ShowList + { + get => _showList; + set => SetProperty(ref _showList, value); + } + + public string NoDataText + { + get => _noDataText; + set => SetProperty(ref _noDataText, value); + } + + public bool WebsiteIconsEnabled + { + get => _websiteIconsEnabled; + set => SetProperty(ref _websiteIconsEnabled, value); + } + + public virtual bool ShowCallout => false; + + public abstract void Init(Models.AppOptions options); + + public async Task LoadAsync() + { + LoadedOnce = true; + ShowList = false; + ShowNoData = false; + WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault(); + + var groupedItems = await LoadGroupedItemsAsync(); + + // TODO: refactor this + if (Device.RuntimePlatform == Device.Android + || + GroupedItems.Any()) + { + var items = new List<IGroupingsPageListItem>(); + foreach (var itemGroup in groupedItems) + { + items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount)); + items.AddRange(itemGroup); + } + + GroupedItems.ReplaceRange(items); + } + else + { + // HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list + var first = true; + var items = new List<IGroupingsPageListItem>(); + foreach (var itemGroup in groupedItems) + { + if (!first) + { + items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount)); + } + else + { + first = false; + } + items.AddRange(itemGroup); + } + + await Device.InvokeOnMainThreadAsync(() => + { + if (groupedItems.Any()) + { + GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) }); + GroupedItems.AddRange(items); + } + else + { + GroupedItems.Clear(); + } + }); + } + await Device.InvokeOnMainThreadAsync(() => + { + ShowList = groupedItems.Any(); + ShowNoData = !ShowList; + }); + } + + protected abstract Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync(); + + protected abstract Task SelectCipherAsync(IGroupingsPageListItem item); + + protected abstract Task AddCipherAsync(); + } +} diff --git a/src/App/Pages/Vault/CiphersPage.xaml b/src/App/Pages/Vault/CiphersPage.xaml index def72c316..36b9e01c0 100644 --- a/src/App/Pages/Vault/CiphersPage.xaml +++ b/src/App/Pages/Vault/CiphersPage.xaml @@ -81,12 +81,22 @@ VerticalOptions="CenterAndExpand" HorizontalOptions="CenterAndExpand" HorizontalTextAlignment="Center" /> - <Label IsVisible="{Binding ShowNoData}" - Text="{u:I18n NoItemsToList}" - Margin="20, 0" - VerticalOptions="CenterAndExpand" - HorizontalOptions="CenterAndExpand" - HorizontalTextAlignment="Center" /> + <StackLayout + HorizontalOptions="Center" + VerticalOptions="StartAndExpand" + Margin="20, 80, 20, 0" + Spacing="20" + IsVisible="{Binding ShowNoData}"> + <Image + Source="empty_items_state" /> + <Label + Text="{u:I18n ThereAreNoItemsThatMatchTheSearch}" + HorizontalTextAlignment="Center" /> + <Button + Text="{u:I18n AddAnItem}" + Command="{Binding AddCipherCommand}" + IsVisible="{Binding ShowAddCipher}"/> + </StackLayout> <controls:ExtendedCollectionView IsVisible="{Binding ShowList}" ItemsSource="{Binding Ciphers}" diff --git a/src/App/Pages/Vault/CiphersPage.xaml.cs b/src/App/Pages/Vault/CiphersPage.xaml.cs index 610fb826c..fc53ce701 100644 --- a/src/App/Pages/Vault/CiphersPage.xaml.cs +++ b/src/App/Pages/Vault/CiphersPage.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Bit.App.Controls; +using Bit.App.Models; using Bit.App.Resources; using Bit.Core.Abstractions; using Bit.Core.Models.View; @@ -17,15 +18,18 @@ namespace Bit.App.Pages private CiphersPageViewModel _vm; private bool _hasFocused; - public CiphersPage(Func<CipherView, bool> filter, string pageTitle = null, string vaultFilterSelection = null, - string autofillUrl = null, bool deleted = false) + public CiphersPage(Func<CipherView, bool> filter, + string pageTitle = null, + string vaultFilterSelection = null, + bool deleted = false, + AppOptions appOptions = null) { InitializeComponent(); _vm = BindingContext as CiphersPageViewModel; _vm.Page = this; - _vm.Filter = filter; - _vm.AutofillUrl = _autofillUrl = autofillUrl; - _vm.Deleted = deleted; + _autofillUrl = appOptions?.Uri; + _vm.Prepare(filter, deleted, appOptions); + if (pageTitle != null) { _vm.PageTitle = string.Format(AppResources.SearchGroup, pageTitle); diff --git a/src/App/Pages/Vault/CiphersPageViewModel.cs b/src/App/Pages/Vault/CiphersPageViewModel.cs index aaa505d92..7ca3f82d8 100644 --- a/src/App/Pages/Vault/CiphersPageViewModel.cs +++ b/src/App/Pages/Vault/CiphersPageViewModel.cs @@ -3,13 +3,16 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Abstractions; +using Bit.App.Models; using Bit.App.Resources; using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.View; using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; using Xamarin.Forms; namespace Bit.App.Pages @@ -31,6 +34,7 @@ namespace Bit.App.Pages private bool _showNoData; private bool _showList; private bool _websiteIconsEnabled; + private AppOptions _appOptions; public CiphersPageViewModel() { @@ -46,14 +50,21 @@ namespace Bit.App.Pages _logger = ServiceContainer.Resolve<ILogger>("logger"); Ciphers = new ExtendedObservableCollection<CipherView>(); - CipherOptionsCommand = new Command<CipherView>(CipherOptionsAsync); + CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService), + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + AddCipherCommand = new AsyncCommand(AddCipherAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); } - public Command CipherOptionsCommand { get; set; } + public ICommand CipherOptionsCommand { get; } + public ICommand AddCipherCommand { get; } public ExtendedObservableCollection<CipherView> Ciphers { get; set; } public Func<CipherView, bool> Filter { get; set; } public string AutofillUrl { get; set; } public bool Deleted { get; set; } + public bool ShowAllIfSearchTextEmpty { get; set; } protected override ICipherService cipherService => _cipherService; protected override IPolicyService policyService => _policyService; @@ -65,7 +76,8 @@ namespace Bit.App.Pages get => _showNoData; set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[] { - nameof(ShowSearchDirection) + nameof(ShowSearchDirection), + nameof(ShowAddCipher) }); } @@ -80,12 +92,23 @@ namespace Bit.App.Pages public bool ShowSearchDirection => !ShowList && !ShowNoData; + public bool ShowAddCipher => ShowNoData && _appOptions?.OtpData != null; + public bool WebsiteIconsEnabled { get => _websiteIconsEnabled; set => SetProperty(ref _websiteIconsEnabled, value); } + internal void Prepare(Func<CipherView, bool> filter, bool deleted, AppOptions appOptions) + { + Filter = filter; + AutofillUrl = appOptions?.Uri; + Deleted = deleted; + ShowAllIfSearchTextEmpty = appOptions?.OtpData != null; + _appOptions = appOptions; + } + public async Task InitAsync() { await InitVaultFilterAsync(true); @@ -101,25 +124,33 @@ namespace Bit.App.Pages { List<CipherView> ciphers = null; var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1; - if (searchable) + var shouldShowAllWhenEmpty = ShowAllIfSearchTextEmpty && string.IsNullOrEmpty(searchText); + if (searchable || shouldShowAllWhenEmpty) { if (timeout != null) { await Task.Delay(timeout.Value); } - if (searchText != (Page as CiphersPage).SearchBar.Text) + if (searchText != (Page as CiphersPage).SearchBar.Text + && + !shouldShowAllWhenEmpty) { return; } - else - { - previousCts?.Cancel(); - } + + previousCts?.Cancel(); try { var vaultFilteredCiphers = await GetAllCiphersAsync(); - ciphers = await _searchService.SearchCiphersAsync(searchText, - Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token); + if (!shouldShowAllWhenEmpty) + { + ciphers = await _searchService.SearchCiphersAsync(searchText, + Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token); + } + else + { + ciphers = vaultFilteredCiphers; + } cts.Token.ThrowIfCancellationRequested(); } catch (OperationCanceledException) @@ -134,8 +165,8 @@ namespace Bit.App.Pages Device.BeginInvokeOnMainThread(() => { Ciphers.ResetWithRange(ciphers); - ShowNoData = searchable && Ciphers.Count == 0; - ShowList = searchable && !ShowNoData; + ShowNoData = !shouldShowAllWhenEmpty && searchable && Ciphers.Count == 0; + ShowList = (searchable || shouldShowAllWhenEmpty) && !ShowNoData; }); }, cts.Token); _searchCancellationTokenSource = cts; @@ -144,6 +175,7 @@ namespace Bit.App.Pages public async Task SelectCipherAsync(CipherView cipher) { string selection = null; + if (!string.IsNullOrWhiteSpace(AutofillUrl)) { var options = new List<string> { AppResources.Autofill }; @@ -156,6 +188,19 @@ namespace Bit.App.Pages selection = await Page.DisplayActionSheet(AppResources.AutofillOrView, AppResources.Cancel, null, options.ToArray()); } + + if (_appOptions?.OtpData != null) + { + if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync()) + { + return; + } + + var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions); + await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage)); + return; + } + if (selection == AppResources.View || string.IsNullOrWhiteSpace(AutofillUrl)) { var page = new CipherDetailsPage(cipher.Id); @@ -205,7 +250,7 @@ namespace Bit.App.Pages private void PerformSearchIfPopulated() { - if (!string.IsNullOrWhiteSpace((Page as CiphersPage).SearchBar.Text)) + if (!string.IsNullOrWhiteSpace((Page as CiphersPage).SearchBar.Text) || ShowAllIfSearchTextEmpty) { Search((Page as CiphersPage).SearchBar.Text, 200); } @@ -216,12 +261,10 @@ namespace Bit.App.Pages PerformSearchIfPopulated(); } - private async void CipherOptionsAsync(CipherView cipher) + private async Task AddCipherAsync() { - if ((Page as BaseContentPage).DoOnce()) - { - await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService); - } + var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: _appOptions?.OtpData?.Issuer ?? _appOptions?.OtpData?.AccountName, appOptions: _appOptions); + await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin)); } } } diff --git a/src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs b/src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs new file mode 100644 index 000000000..537823194 --- /dev/null +++ b/src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bit.App.Resources; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class OTPCipherSelectionPageViewModel : CipherSelectionPageViewModel + { + private readonly ISearchService _searchService = ServiceContainer.Resolve<ISearchService>(); + + private OtpData _otpData; + private Models.AppOptions _appOptions; + + public override bool ShowCallout => !ShowNoData; + + public override void Init(Models.AppOptions options) + { + _appOptions = options; + _otpData = options.OtpData.Value; + + Name = _otpData.Issuer ?? _otpData.AccountName; + PageTitle = string.Format(AppResources.ItemsForUri, Name ?? "--"); + NoDataText = string.Format(AppResources.ThereAreNoItemsInYourVaultThatMatchX, Name ?? "--") + + Environment.NewLine + + AppResources.SearchForAnItemOrAddANewItem; + } + + protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync() + { + var groupedItems = new List<GroupingsPageListGroup>(); + var allCiphers = await _cipherService.GetAllDecryptedAsync(); + var ciphers = await _searchService.SearchCiphersAsync(_otpData.Issuer ?? _otpData.AccountName, + c => c.Type == CipherType.Login && !c.IsDeleted, allCiphers); + + if (ciphers?.Any() ?? false) + { + groupedItems.Add( + new GroupingsPageListGroup(ciphers.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(), + AppResources.MatchingItems, + ciphers.Count, + false, + true)); + } + + return groupedItems; + } + + protected override async Task SelectCipherAsync(IGroupingsPageListItem item) + { + if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null) + { + return; + } + + var cipher = listItem.Cipher; + + if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync()) + { + return; + } + + var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions); + await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage)); + return; + } + + protected override async Task AddCipherAsync() + { + var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: Name, appOptions: _appOptions); + await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin)); + } + } +} diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index 19717e7ca..16763104f 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -1,7 +1,6 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // <auto-generated> // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -14,12 +13,10 @@ namespace Bit.App.Resources { /// <summary> /// A strongly-typed resource class, for looking up localized strings, etc. + /// This class was generated by MSBuild using the GenerateResource task. + /// To add or remove a member, edit your .resx file then rerun MSBuild. /// </summary> - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Build.Tasks.StronglyTypedResourceBuilder", "15.1.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class AppResources { @@ -385,6 +382,15 @@ namespace Bit.App.Resources { } } + /// <summary> + /// Looks up a localized string similar to Add the key to an existing or new item. + /// </summary> + public static string AddTheKeyToAnExistingOrNewItem { + get { + return ResourceManager.GetString("AddTheKeyToAnExistingOrNewItem", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Add TOTP. /// </summary> @@ -5339,6 +5345,15 @@ namespace Bit.App.Resources { } } + /// <summary> + /// Looks up a localized string similar to Search for an item or add a new item. + /// </summary> + public static string SearchForAnItemOrAddANewItem { + get { + return ResourceManager.GetString("SearchForAnItemOrAddANewItem", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Search {0}. /// </summary> @@ -6032,6 +6047,24 @@ namespace Bit.App.Resources { } } + /// <summary> + /// Looks up a localized string similar to There are no items in your vault that match "{0}". + /// </summary> + public static string ThereAreNoItemsInYourVaultThatMatchX { + get { + return ResourceManager.GetString("ThereAreNoItemsInYourVaultThatMatchX", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to There are no items that match the search. + /// </summary> + public static string ThereAreNoItemsThatMatchTheSearch { + get { + return ResourceManager.GetString("ThereAreNoItemsThatMatchTheSearch", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to 30 days. /// </summary> diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 3f40e4bcd..61742c5f6 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -2592,4 +2592,16 @@ Do you want to switch to this account?</value> <data name="OrganizationSsoIdentifierRequired" xml:space="preserve"> <value>Organization SSO identifier required.</value> </data> + <data name="AddTheKeyToAnExistingOrNewItem" xml:space="preserve"> + <value>Add the key to an existing or new item</value> + </data> + <data name="ThereAreNoItemsInYourVaultThatMatchX" xml:space="preserve"> + <value>There are no items in your vault that match "{0}"</value> + </data> + <data name="SearchForAnItemOrAddANewItem" xml:space="preserve"> + <value>Search for an item or add a new item</value> + </data> + <data name="ThereAreNoItemsThatMatchTheSearch" xml:space="preserve"> + <value>There are no items that match the search</value> + </data> </root> diff --git a/src/App/Services/DeepLinkContext.cs b/src/App/Services/DeepLinkContext.cs new file mode 100644 index 000000000..6c696aa70 --- /dev/null +++ b/src/App/Services/DeepLinkContext.cs @@ -0,0 +1,30 @@ +using System; +using Bit.App.Abstractions; +using Bit.Core; +using Bit.Core.Abstractions; + +namespace Bit.App.Services +{ + public class DeepLinkContext : IDeepLinkContext + { + public const string NEW_OTP_MESSAGE = "handleOTPUriMessage"; + + private readonly IMessagingService _messagingService; + + public DeepLinkContext(IMessagingService messagingService) + { + _messagingService = messagingService; + } + + public bool OnNewUri(Uri uri) + { + if (uri.Scheme == Constants.OtpAuthScheme) + { + _messagingService.Send(NEW_OTP_MESSAGE, uri.AbsoluteUri); + return true; + } + + return false; + } + } +} diff --git a/src/App/Utilities/AccountManagement/AccountsManager.cs b/src/App/Utilities/AccountManagement/AccountsManager.cs index c8cf16a38..b08714b19 100644 --- a/src/App/Utilities/AccountManagement/AccountsManager.cs +++ b/src/App/Utilities/AccountManagement/AccountsManager.cs @@ -7,7 +7,6 @@ using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Models.Domain; using Bit.Core.Utilities; -using Xamarin.Essentials; using Xamarin.Forms; namespace Bit.App.Utilities.AccountManagement @@ -58,6 +57,13 @@ namespace Bit.App.Utilities.AccountManagement _broadcasterService.Subscribe(nameof(AccountsManager), OnMessage); } + public async Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction) + { + appOptionsAction(Options); + + await NavigateOnAccountChangeAsync(); + } + public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null) { // TODO: this could be improved by doing chain of responsability pattern @@ -89,6 +95,10 @@ namespace Bit.App.Utilities.AccountManagement { _accountsManagerHost.Navigate(NavigationTarget.AutofillCiphers); } + else if (Options.OtpData != null) + { + _accountsManagerHost.Navigate(NavigationTarget.OtpCipherSelection); + } else if (Options.CreateSend != null) { _accountsManagerHost.Navigate(NavigationTarget.SendAddEdit); diff --git a/src/App/Utilities/AppHelpers.cs b/src/App/Utilities/AppHelpers.cs index 07870b0e1..48c0fb73a 100644 --- a/src/App/Utilities/AppHelpers.cs +++ b/src/App/Utilities/AppHelpers.cs @@ -430,9 +430,11 @@ namespace Bit.App.Utilities Application.Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: appOptions)); return true; } - if (appOptions.Uri != null) + if (appOptions.Uri != null + || + appOptions.OtpData != null) { - Application.Current.MainPage = new NavigationPage(new AutofillCiphersPage(appOptions)); + Application.Current.MainPage = new NavigationPage(new CipherSelectionPage(appOptions)); return true; } if (appOptions.CreateSend != null) diff --git a/src/App/Utilities/AppSetup.cs b/src/App/Utilities/AppSetup.cs index ff7d7eea6..02ae89eea 100644 --- a/src/App/Utilities/AppSetup.cs +++ b/src/App/Utilities/AppSetup.cs @@ -1,4 +1,6 @@ -using Bit.App.Lists.ItemViewModels.CustomFields; +using Bit.App.Abstractions; +using Bit.App.Lists.ItemViewModels.CustomFields; +using Bit.App.Services; using Bit.Core.Abstractions; using Bit.Core.Utilities; @@ -18,6 +20,7 @@ namespace Bit.App.Utilities // TODO: This could be further improved by Lazy Registration since it may not be needed at all ServiceContainer.Register<ICustomFieldItemFactory>("customFieldItemFactory", new CustomFieldItemFactory(i18nService, eventService)); + ServiceContainer.Register<IDeepLinkContext>(new DeepLinkContext(ServiceContainer.Resolve<IMessagingService>())); } } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index beb8c6d99..5db98b756 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -46,6 +46,7 @@ /// which is used to handle Apple Watch state logic /// </summary> public const string LastUserShouldConnectToWatchKey = "lastUserShouldConnectToWatch"; + public const string OtpAuthScheme = "otpauth"; public const string AppLocaleKey = "appLocale"; public const string ClearSensitiveFields = "clearSensitiveFields"; public const int SelectFileRequestCode = 42; diff --git a/src/Core/Enums/NavigationTarget.cs b/src/Core/Enums/NavigationTarget.cs index d226ff283..8cb83ebf5 100644 --- a/src/Core/Enums/NavigationTarget.cs +++ b/src/Core/Enums/NavigationTarget.cs @@ -8,6 +8,7 @@ Home, AddEditCipher, AutofillCiphers, - SendAddEdit + SendAddEdit, + OtpCipherSelection } } diff --git a/src/Core/Services/TotpService.cs b/src/Core/Services/TotpService.cs index fe0b83649..4f1e453ec 100644 --- a/src/Core/Services/TotpService.cs +++ b/src/Core/Services/TotpService.cs @@ -33,39 +33,22 @@ namespace Bit.Core.Services var isSteamAuth = key?.ToLowerInvariant().StartsWith("steam://") ?? false; if (isOtpAuth) { - var qsParams = CoreHelpers.GetQueryParams(key); - if (qsParams.ContainsKey("digits") && qsParams["digits"] != null && - int.TryParse(qsParams["digits"].Trim(), out var digitParam)) + var otpData = new OtpData(key.ToLowerInvariant()); + if (otpData.Digits > 0) { - if (digitParam > 10) - { - digits = 10; - } - else if (digitParam > 0) - { - digits = digitParam; - } + digits = Math.Min(otpData.Digits.Value, 10); } - if (qsParams.ContainsKey("period") && qsParams["period"] != null && - int.TryParse(qsParams["period"].Trim(), out var periodParam) && periodParam > 0) + if (otpData.Period.HasValue) { - period = periodParam; + period = otpData.Period.Value; } - if (qsParams.ContainsKey("secret") && qsParams["secret"] != null) + if (otpData.Secret != null) { - keyB32 = qsParams["secret"]; + keyB32 = otpData.Secret; } - if (qsParams.ContainsKey("algorithm") && qsParams["algorithm"] != null) + if (otpData.Algorithm.HasValue) { - var algParam = qsParams["algorithm"].ToLowerInvariant(); - if (algParam == "sha256") - { - alg = CryptoHashAlgorithm.Sha256; - } - else if (algParam == "sha512") - { - alg = CryptoHashAlgorithm.Sha512; - } + alg = otpData.Algorithm.Value; } } else if (isSteamAuth) diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 8c63374e3..2fa4f4ad0 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -168,10 +168,27 @@ namespace Bit.Core.Utilities { try { - if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Query)) + if (Uri.TryCreate(urlString, UriKind.Absolute, out var uri)) + { + return GetQueryParams(uri); + } + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + } + return new Dictionary<string, string>(); + } + + public static Dictionary<string, string> GetQueryParams(Uri uri) + { + try + { + if (string.IsNullOrWhiteSpace(uri.Query)) { return new Dictionary<string, string>(); } + var queryStringNameValueCollection = HttpUtility.ParseQueryString(uri.Query); return queryStringNameValueCollection.AllKeys.Where(k => k != null).ToDictionary(k => k, k => queryStringNameValueCollection[k]); } diff --git a/src/Core/Utilities/OtpData.cs b/src/Core/Utilities/OtpData.cs new file mode 100644 index 000000000..5306f8adf --- /dev/null +++ b/src/Core/Utilities/OtpData.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using Bit.Core.Enums; + +namespace Bit.Core.Utilities +{ + public struct OtpData + { + const string LABEL_SEPARATOR = ":"; + + public OtpData(string absoluteUri) + { + if (!System.Uri.TryCreate(absoluteUri, UriKind.Absolute, out var uri) + || + uri.Scheme != Constants.OtpAuthScheme) + { + throw new InvalidOperationException("Cannot create OtpData. Invalid OTP uri"); + } + + Uri = absoluteUri; + AccountName = null; + Issuer = null; + Secret = null; + Digits = null; + Period = null; + Algorithm = null; + + var escapedlabel = uri.Segments.Last(); + if (escapedlabel != "/") + { + var label = UriExtensions.UnescapeDataString(escapedlabel); + if (label.Contains(LABEL_SEPARATOR)) + { + var parts = label.Split(LABEL_SEPARATOR); + AccountName = parts[0].Trim(); + Issuer = parts[1].Trim(); + } + else + { + AccountName = label.Trim(); + } + } + + var qsParams = CoreHelpers.GetQueryParams(uri); + if (Issuer is null && qsParams.TryGetValue("issuer", out var issuer)) + { + Issuer = issuer; + } + + if (qsParams.TryGetValue("secret", out var secret)) + { + Secret = secret; + } + + if (qsParams.TryGetValue("digits", out var digitParam) + && + int.TryParse(digitParam?.Trim(), out var digits)) + { + Digits = digits; + } + + if (qsParams.TryGetValue("period", out var periodParam) + && + int.TryParse(periodParam?.Trim(), out var period) + && + period > 0) + { + Period = period; + } + + if (qsParams.TryGetValue("algorithm", out var algParam) + && + algParam?.ToLower() is string alg) + { + if (alg == "sha256") + { + Algorithm = CryptoHashAlgorithm.Sha256; + } + else if (alg == "sha512") + { + Algorithm = CryptoHashAlgorithm.Sha512; + } + } + } + + public string Uri { get; } + public string AccountName { get; } + public string Issuer { get; } + public string Secret { get; } + public int? Digits { get; } + public int? Period { get; } + public CryptoHashAlgorithm? Algorithm { get; } + } +} diff --git a/src/Core/Utilities/UriExtensions.cs b/src/Core/Utilities/UriExtensions.cs new file mode 100644 index 000000000..aee97136e --- /dev/null +++ b/src/Core/Utilities/UriExtensions.cs @@ -0,0 +1,18 @@ +using System; + +namespace Bit.Core.Utilities +{ + public static class UriExtensions + { + public static string UnescapeDataString(string uriString) + { + string unescapedUri; + while ((unescapedUri = System.Uri.UnescapeDataString(uriString)) != uriString) + { + uriString = unescapedUri; + } + + return unescapedUri; + } + } +} diff --git a/src/iOS/AppDelegate.cs b/src/iOS/AppDelegate.cs index 05f294df5..284f47299 100644 --- a/src/iOS/AppDelegate.cs +++ b/src/iOS/AppDelegate.cs @@ -43,6 +43,8 @@ namespace Bit.iOS private IStateService _stateService; private IEventService _eventService; + private LazyResolve<IDeepLinkContext> _deepLinkContext = new LazyResolve<IDeepLinkContext>(); + public override bool FinishedLaunching(UIApplication app, NSDictionary options) { Forms.Init(); @@ -239,7 +241,7 @@ namespace Bit.iOS public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) { - return Xamarin.Essentials.Platform.OpenUrl(app, url, options); + return _deepLinkContext.Value.OnNewUri(url) || Xamarin.Essentials.Platform.OpenUrl(app, url, options); } public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity, diff --git a/src/iOS/Info.plist b/src/iOS/Info.plist index 2a07b3ba4..86e7604fe 100644 --- a/src/iOS/Info.plist +++ b/src/iOS/Info.plist @@ -29,6 +29,14 @@ <key>CFBundleURLName</key> <string>com.8bit.bitwarden.url</string> </dict> + <dict> + <key>CFBundleURLName</key> + <string>com.8bit.bitwarden</string> + <key>CFBundleURLSchemes</key> + <array> + <string>otpauth</string> + </array> + </dict> </array> <key>CFBundleLocalizations</key> <array> diff --git a/src/iOS/Resources/Assets.xcassets/Contents.json b/src/iOS/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/src/iOS/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json b/src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json new file mode 100644 index 000000000..2c8771189 --- /dev/null +++ b/src/iOS/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/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf b/src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f68a9a3b66fd8447ebe1690f4e7d1d90bd5e3542 GIT binary patch literal 1891 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 literal 0 HcmV?d00001 diff --git a/src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf b/src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1aa4dc22fd4f083f775ca4ea6a65bdcb4e71ead6 GIT binary patch literal 1875 zcmZWq4Ok3m9B0w7r8rzyhep}#s&;1EomPa~cI`?!wLB;m(>Ar6+Sz7j(?(L+)XGOG z$rEwD>*(`*Q*20x;!bpTLc-;<l9F^Y+lL<aKJ&~w@9&-e$M64t=Qj*rk=TX9=Fu6y zzdq4P=K&Ca1%}c+JOFS$stO?#08BzsIsgEmSQ$p3I5~$Q1S&%1SP)8|HH)qxa1;rr zM`a&hd`p$hw>aO#YNh2FKHY~iX@l{(xJC5vi!_$u!=;y#@^fsYMHg1&CzR<^M@sCx zQXSIL+LKCm)JrBOF6xSS{zhgo^OpT};n8#;rB-tN%iWwW+w!YpJ8z@i#^l&-vE66E zSb@W*QrUaE&ljw<t4r0jVg;;A9x}rscGHE>efKZmZ8=>M>&LGuBO9PqGtBMdp2cS- zE)i=Imo_C-$7hObC&WG~?YO^VOU0YY*wNCA(0yxIEN8VDQ?Ppy>$8tbO3|~p9C;jT ze`e#oxe=BJ%f~~lH9Ox9v&1^tQEj@!>3l->Lh_*~$Uo9d`?1B$cBk%+!cK!1v_E&f zw#H-P#H`lFeErHI=DX&n!zN0$`^QuX0_`)-=sm8j7{ooZDV^_V+ji!oWRd6QWWn1E zKcSO6tI5;6?8bu^g0T-)n8z<Szj|C>49;w5+qW`6(H?fftzgU6EiPksPmOe{d8R-k zvPz^~DT!+W_JX{GhH?YDa$N3%hM#;2((@EXc&}r_vdTuEj1a5*+})3Uc*$pc(y#OO zHz=!4r>uXhopso(Be;a)EX%HH%56MlwP(oZ?murPSw+jdyH8H7l71-j>%J8cC~VQE zrp>Du4c{?}uKd-Zcbeyr48Du=Y+|JEjwx=p7{5Mg>k?ztg1p1%nLEvE!<-c<*(X_b zce4$a%(^O8K%8^J4aooe?IE#EM^6Wfh1ug`BsHlh`?u<Qf|x(Ph@}%YC#?3~+m;u) z>6ClD?-WHLJhTvGJEqLGEPoIqWC(0>N9t+m`qx=bGG64_f;n7g-@*HzwE8U@;dN@` zh)MS{poR7s;iD!8&_rSJ<2m>D=lYbU83f{Lr$LwN%NjlW_x%iaZf?zZ=RND6N<Jn# z*YL~jq}h*)P%m}n7?t5fvrY5xis8*8xaoP^4Nb$oPp)m=IOK?<L2x-eJV#47Uo%^! zSZllSZ)ORUw1F_VfM$d_%wvWgYNp><4n5qnO{QLOcWp~oTTO{L^`>k1VU<o{m%~@> zk{1?CUFyIbw?4f3qRsU2`5A02nC-7W@N7$5pkH}>#X6|WSP}i+I6Ls_$wTw6Sr1x$ z?$=$KkH?<+m$$dNM85OdKJ-V>y*wrVc*{xqD>JR~t@m7MJkLN7Zjkqp?eCx1jhS+L zfwKM4CS!f)!N9sjQ8xEtG{;IV+pPO{Y@&NRt;QpmUYvX=Zm}VacO;oFSR%CKwk_Q+ z6qVT;>q4(KuJ()UI7@SB*jfCY;{j&N@#3-q-{KdAH?QvLeAQX8q4r_tMjOQ2YRZX1 zqo4JtE}o!Bmj!ivezBgGCYgVUj;exszjpG8(g{i&sF{=pcXycDr&ip46*1|?oG3!F zkRUY!@bI7ja)1a#E&xy{05||BN60&=B;*EpC$C;VuKj)haFI@p0-!Gvf&vVHWAf{Z z;HZiqP5T7O2Mtmpo>&yHjD#S71G};LFu>tJY*!eNnQVlZiXe?N0Ht(2vL?YXt$ILe zJ&{akU$zU$_&B1{sHuo@T_5ZP5V#iYTM5axL};W^j!Gq-6xhQEl%g6;i_1|BK-EP$ zfFG54pW1%qr=e^?Z)K2%<Uy4{+Mm0YqC3EW+W-f?I2NRpllsh*BT5_tIBX8g=C}e( z1wp8%gJh9OGz3S~3Z-1b#_$jp%M?gRsyCs;R3fTDOwn{#$dwCmAwFjsm&=0!SOdcG z2ZZa%SBMZu7#3o3MD<3~+l{Y1jKa}iIs{CkL;VAAgJCWV1OvSohX;}8h#G*ZALC3T ztMe@exqpj65a<84&@?jBZ)|yd@<@Dx!2@FuIHC+ganrAFp)wjJQ>OYL#V`V(i1!>8 dFI6xGP-QpuQ9~d&VPeQ}<8tW?#+(IW`hVw|&?*1` literal 0 HcmV?d00001 diff --git a/src/iOS/Resources/Assets.xcassets/ic_warning.imageset/Contents.json b/src/iOS/Resources/Assets.xcassets/ic_warning.imageset/Contents.json index 3c275c296..5a1234c62 100644 --- a/src/iOS/Resources/Assets.xcassets/ic_warning.imageset/Contents.json +++ b/src/iOS/Resources/Assets.xcassets/ic_warning.imageset/Contents.json @@ -1,528 +1,608 @@ { - "images": [ + "images" : [ { - "filename": "ic_warning-1.pdf", - "idiom": "universal" + "filename" : "ic_warning-1.pdf", + "idiom" : "universal" }, { - "scale": "1x", - "idiom": "universal" - }, - { - "scale": "2x", - "idiom": "universal" - }, - { - "scale": "3x", - "idiom": "universal" - }, - { - "idiom": "iphone" - }, - { - "scale": "1x", - "idiom": "iphone" - }, - { - "scale": "2x", - "idiom": "iphone" - }, - { - "subtype": "retina4", - "scale": "2x", - "idiom": "iphone" - }, - { - "scale": "3x", - "idiom": "iphone" - }, - { - "idiom": "ipad" - }, - { - "scale": "1x", - "idiom": "ipad" - }, - { - "scale": "2x", - "idiom": "ipad" - }, - { - "idiom": "watch" - }, - { - "scale": "2x", - "idiom": "watch" - }, - { - "screenWidth": "{130,145}", - "scale": "2x", - "idiom": "watch" - }, - { - "screenWidth": "{146,165}", - "scale": "2x", - "idiom": "watch" - }, - { - "idiom": "mac" - }, - { - "scale": "1x", - "idiom": "mac" - }, - { - "scale": "2x", - "idiom": "mac" - }, - { - "idiom": "car" - }, - { - "scale": "2x", - "idiom": "car" - }, - { - "scale": "3x", - "idiom": "car" - }, - { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "idiom": "universal" + "idiom" : "universal" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "1x", - "idiom": "universal" + "idiom" : "universal" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "2x", - "idiom": "universal" + "idiom" : "universal", + "scale" : "1x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "3x", - "idiom": "universal" + "idiom" : "universal", + "scale" : "1x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "iphone" + "idiom" : "universal", + "scale" : "1x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "1x", - "idiom": "iphone" + "idiom" : "universal", + "scale" : "2x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "iphone" + "idiom" : "universal", + "scale" : "2x" }, { - "subtype": "retina4", - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "2x", - "idiom": "iphone" + "idiom" : "universal", + "scale" : "2x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "3x", - "idiom": "iphone" + "idiom" : "universal", + "scale" : "3x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "idiom": "ipad" + "idiom" : "universal", + "scale" : "3x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "1x", - "idiom": "ipad" + "idiom" : "universal", + "scale" : "3x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "2x", - "idiom": "ipad" + "idiom" : "iphone" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "idiom": "watch" + "idiom" : "iphone" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "2x", - "idiom": "watch" + "idiom" : "iphone" }, { - "screenWidth": "{130,145}", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "2x", - "idiom": "watch" + "idiom" : "iphone", + "scale" : "1x" }, { - "screenWidth": "{146,165}", - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "watch" + "idiom" : "iphone", + "scale" : "1x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "mac" + "idiom" : "iphone", + "scale" : "1x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "1x", - "idiom": "mac" + "idiom" : "iphone", + "scale" : "2x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "mac" + "idiom" : "iphone", + "scale" : "2x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "car" + "idiom" : "iphone", + "scale" : "2x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "scale": "2x", - "idiom": "car" + "idiom" : "iphone", + "scale" : "3x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "dark" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "3x", - "idiom": "car" + "idiom" : "iphone", + "scale" : "3x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "universal" + "idiom" : "iphone", + "scale" : "3x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "scale": "1x", - "idiom": "universal" + "idiom" : "iphone", + "scale" : "1x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "universal" + "idiom" : "iphone", + "scale" : "1x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "3x", - "idiom": "universal" + "idiom" : "iphone", + "scale" : "1x", + "subtype" : "retina4" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "idiom": "iphone" + "idiom" : "iphone", + "scale" : "2x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "1x", - "idiom": "iphone" + "idiom" : "iphone", + "scale" : "2x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "2x", - "idiom": "iphone" + "idiom" : "iphone", + "scale" : "2x", + "subtype" : "retina4" }, { - "subtype": "retina4", - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "scale": "2x", - "idiom": "iphone" + "idiom" : "iphone", + "scale" : "3x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "3x", - "idiom": "iphone" + "idiom" : "iphone", + "scale" : "3x", + "subtype" : "retina4" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "ipad" + "idiom" : "iphone", + "scale" : "3x", + "subtype" : "retina4" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "scale": "1x", - "idiom": "ipad" + "idiom" : "ipad" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "ipad" + "idiom" : "ipad" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "idiom": "watch" + "idiom" : "ipad" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "scale": "2x", - "idiom": "watch" + "idiom" : "ipad", + "scale" : "1x" }, { - "screenWidth": "{130,145}", - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "watch" + "idiom" : "ipad", + "scale" : "1x" }, { - "screenWidth": "{146,165}", - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "2x", - "idiom": "watch" + "idiom" : "ipad", + "scale" : "1x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "idiom": "mac" + "idiom" : "ipad", + "scale" : "2x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "1x", - "idiom": "mac" + "idiom" : "ipad", + "scale" : "2x" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "2x", - "idiom": "mac" + "idiom" : "ipad", + "scale" : "2x" }, { - "appearances": [ - { - "appearance": "luminosity", - "value": "light" - } - ], - "idiom": "car" + "idiom" : "car" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "light" } ], - "scale": "2x", - "idiom": "car" + "idiom" : "car" }, { - "appearances": [ + "appearances" : [ { - "appearance": "luminosity", - "value": "light" + "appearance" : "luminosity", + "value" : "dark" } ], - "scale": "3x", - "idiom": "car" + "idiom" : "car" + }, + { + "idiom" : "car", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "car", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "car", + "scale" : "2x" + }, + { + "idiom" : "car", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "car", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "car", + "scale" : "3x" + }, + { + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "mac" + }, + { + "idiom" : "mac", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "mac", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "mac", + "scale" : "1x" + }, + { + "idiom" : "mac", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "mac", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "mac", + "scale" : "2x" + }, + { + "idiom" : "watch" + }, + { + "idiom" : "watch", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "screen-width" : ">161" + }, + { + "idiom" : "watch", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "screen-width" : ">183" + }, + { + "idiom" : "watch", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">161" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">183" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "watch" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "watch" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "watch", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "watch", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" } ], - "info": { - "version": 1, - "author": "xcode" + "info" : { + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/src/iOS/iOS.csproj b/src/iOS/iOS.csproj index f197763bb..ba4127910 100644 --- a/src/iOS/iOS.csproj +++ b/src/iOS/iOS.csproj @@ -179,6 +179,10 @@ <BundleResource Include="Resources\generate.png" /> <BundleResource Include="Resources\generate%402x.png" /> <BundleResource Include="Resources\generate%403x.png" /> + <ImageAsset Include="Resources\Assets.xcassets\Contents.json" /> + <ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Empty-items-state-dark.pdf" /> + <ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Empty-items-state.pdf" /> + <ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Contents.json" /> </ItemGroup> <ItemGroup> <InterfaceDefinition Include="LaunchScreen.storyboard" />