[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)
This commit is contained in:
Federico Maccaroni 2023-03-09 11:16:48 -03:00 committed by GitHub
parent 4d2b53c809
commit a18f74a72a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1277 additions and 630 deletions

View File

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

View File

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

View File

@ -0,0 +1,9 @@
using System;
namespace Bit.App.Abstractions
{
public interface IDeepLinkContext
{
bool OnNewUri(Uri uri);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &quot;{0}&quot;.
/// </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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
Home,
AddEditCipher,
AutofillCiphers,
SendAddEdit
SendAddEdit,
OtpCipherSelection
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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