diff --git a/src/Android/Properties/AndroidManifest.xml b/src/Android/Properties/AndroidManifest.xml
index 3bac51932..6dd07923a 100644
--- a/src/Android/Properties/AndroidManifest.xml
+++ b/src/Android/Properties/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
diff --git a/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml b/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml
new file mode 100644
index 000000000..00ecc3415
--- /dev/null
+++ b/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml.cs b/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml.cs
new file mode 100644
index 000000000..e4a53c988
--- /dev/null
+++ b/src/App/Controls/AuthenticatorViewCell/AuthenticatorViewCell.xaml.cs
@@ -0,0 +1,67 @@
+using System;
+using Bit.App.Pages;
+using Bit.App.Utilities;
+using Bit.Core.Models.View;
+using Bit.Core.Utilities;
+using Xamarin.Forms;
+
+namespace Bit.App.Controls
+{
+ public partial class AuthenticatorViewCell : ExtendedGrid
+ {
+ public static readonly BindableProperty CipherProperty = BindableProperty.Create(
+ nameof(Cipher), typeof(CipherView), typeof(AuthenticatorViewCell), default(CipherView), BindingMode.TwoWay);
+
+ public static readonly BindableProperty WebsiteIconsEnabledProperty = BindableProperty.Create(
+ nameof(WebsiteIconsEnabled), typeof(bool?), typeof(AuthenticatorViewCell));
+
+ public static readonly BindableProperty TotpSecProperty = BindableProperty.Create(
+ nameof(TotpSec), typeof(long), typeof(AuthenticatorViewCell));
+
+ public AuthenticatorViewCell()
+ {
+ InitializeComponent();
+ }
+
+ public Command CopyCommand { get; set; }
+
+ public CipherView Cipher
+ {
+ get => GetValue(CipherProperty) as CipherView;
+ set => SetValue(CipherProperty, value);
+ }
+
+ public bool? WebsiteIconsEnabled
+ {
+ get => (bool)GetValue(WebsiteIconsEnabledProperty);
+ set => SetValue(WebsiteIconsEnabledProperty, value);
+ }
+
+ public long TotpSec
+ {
+ get => (long)GetValue(TotpSecProperty);
+ set => SetValue(TotpSecProperty, value);
+ }
+
+ public bool ShowIconImage
+ {
+ get => WebsiteIconsEnabled ?? false
+ && !string.IsNullOrWhiteSpace(Cipher.Login?.Uri)
+ && IconImageSource != null;
+ }
+
+ private string _iconImageSource = string.Empty;
+ public string IconImageSource
+ {
+ get
+ {
+ if (_iconImageSource == string.Empty) // default value since icon source can return null
+ {
+ _iconImageSource = IconImageHelper.GetLoginIconImage(Cipher);
+ }
+ return _iconImageSource;
+ }
+
+ }
+ }
+}
diff --git a/src/App/Controls/CircularProgressbarView.cs b/src/App/Controls/CircularProgressbarView.cs
new file mode 100644
index 000000000..56e8a6c43
--- /dev/null
+++ b/src/App/Controls/CircularProgressbarView.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Runtime.CompilerServices;
+using SkiaSharp;
+using SkiaSharp.Views.Forms;
+using Xamarin.Essentials;
+using Xamarin.Forms;
+
+namespace Bit.App.Controls
+{
+ public class CircularProgressbarView : SKCanvasView
+ {
+ private Circle _circle;
+
+ public static readonly BindableProperty ProgressProperty = BindableProperty.Create(
+ nameof(Progress), typeof(double), typeof(CircularProgressbarView), propertyChanged: OnProgressChanged);
+
+ public static readonly BindableProperty RadiusProperty = BindableProperty.Create(
+ nameof(Radius), typeof(float), typeof(CircularProgressbarView), 15f);
+
+ public static readonly BindableProperty StrokeWidthProperty = BindableProperty.Create(
+ nameof(StrokeWidth), typeof(float), typeof(CircularProgressbarView), 3f);
+
+ public static readonly BindableProperty ProgressColorProperty = BindableProperty.Create(
+ nameof(ProgressColor), typeof(Color), typeof(CircularProgressbarView), Color.Default);
+
+ public static readonly BindableProperty EndingProgressColorProperty = BindableProperty.Create(
+ nameof(EndingProgressColor), typeof(Color), typeof(CircularProgressbarView), Color.Default);
+
+ public static readonly BindableProperty BackgroundProgressColorProperty = BindableProperty.Create(
+ nameof(BackgroundProgressColor), typeof(Color), typeof(CircularProgressbarView), Color.Default);
+
+ public double Progress
+ {
+ get { return (double)GetValue(ProgressProperty); }
+ set { SetValue(ProgressProperty, value); }
+ }
+
+ public float Radius
+ {
+ get => (float)GetValue(RadiusProperty);
+ set => SetValue(RadiusProperty, value);
+ }
+ public float StrokeWidth
+ {
+ get => (float)GetValue(StrokeWidthProperty);
+ set => SetValue(StrokeWidthProperty, value);
+ }
+
+ public Color ProgressColor
+ {
+ get => (Color)GetValue(ProgressColorProperty);
+ set => SetValue(ProgressColorProperty, value);
+ }
+
+ public Color EndingProgressColor
+ {
+ get => (Color)GetValue(EndingProgressColorProperty);
+ set => SetValue(EndingProgressColorProperty, value);
+ }
+
+ public Color BackgroundProgressColor
+ {
+ get => (Color)GetValue(BackgroundProgressColorProperty);
+ set => SetValue(BackgroundProgressColorProperty, value);
+ }
+
+ private static void OnProgressChanged(BindableObject bindable, object oldvalue, object newvalue)
+ {
+ var context = bindable as CircularProgressbarView;
+ context.InvalidateSurface();
+ }
+
+ protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(Progress))
+ {
+ _circle = new Circle(Radius * (float)DeviceDisplay.MainDisplayInfo.Density, (info) => new SKPoint((float)info.Width / 2, (float)info.Height / 2));
+ }
+ }
+
+ protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
+ {
+ base.OnPaintSurface(e);
+ if (_circle != null)
+ {
+ _circle.CalculateCenter(e.Info);
+ e.Surface.Canvas.Clear();
+ DrawCircle(e.Surface.Canvas, _circle, StrokeWidth * (float)DeviceDisplay.MainDisplayInfo.Density, BackgroundProgressColor.ToSKColor());
+ DrawArc(e.Surface.Canvas, _circle, () => (float)Progress, StrokeWidth * (float)DeviceDisplay.MainDisplayInfo.Density, ProgressColor.ToSKColor(), EndingProgressColor.ToSKColor());
+ }
+ }
+
+ private void DrawCircle(SKCanvas canvas, Circle circle, float strokewidth, SKColor color)
+ {
+ canvas.DrawCircle(circle.Center, circle.Redius,
+ new SKPaint()
+ {
+ StrokeWidth = strokewidth,
+ Color = color,
+ IsStroke = true,
+ IsAntialias = true
+ });
+ }
+
+ private void DrawArc(SKCanvas canvas, Circle circle, Func progress, float strokewidth, SKColor color, SKColor progressEndColor)
+ {
+ var progressValue = progress();
+ var angle = progressValue * 3.6f;
+ canvas.DrawArc(circle.Rect, 270, angle, false,
+ new SKPaint()
+ {
+ StrokeWidth = strokewidth,
+ Color = progressValue < 20f ? progressEndColor : color,
+ IsStroke = true,
+ IsAntialias = true
+ });
+ }
+ }
+
+ public class Circle
+ {
+ private readonly Func _centerFunc;
+
+ public Circle(float redius, Func centerFunc)
+ {
+ _centerFunc = centerFunc;
+ Redius = redius;
+ }
+ public SKPoint Center { get; set; }
+ public float Redius { get; set; }
+ public SKRect Rect => new SKRect(Center.X - Redius, Center.Y - Redius, Center.X + Redius, Center.Y + Redius);
+
+ public void CalculateCenter(SKImageInfo argsInfo)
+ {
+ Center = _centerFunc(argsInfo);
+ }
+ }
+}
diff --git a/src/App/Pages/Vault/CipherAddEditPage.xaml b/src/App/Pages/Vault/CipherAddEditPage.xaml
index 37855416f..2c8a03b59 100644
--- a/src/App/Pages/Vault/CipherAddEditPage.xaml
+++ b/src/App/Pages/Vault/CipherAddEditPage.xaml
@@ -184,31 +184,61 @@
+
+
+
+
+
+
+
+
diff --git a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs
index 6d6fc9ae6..c4c263bc9 100644
--- a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs
+++ b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs
@@ -10,6 +10,7 @@ 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
@@ -24,6 +25,7 @@ namespace Bit.App.Pages
private readonly IMessagingService _messagingService;
private readonly IEventService _eventService;
private readonly IPolicyService _policyService;
+ private readonly IClipboardService _clipboardService;
private bool _showNotesSeparator;
private bool _showPassword;
@@ -47,6 +49,7 @@ namespace Bit.App.Pages
nameof(ShowUris),
nameof(ShowAttachments),
nameof(ShowCollections),
+ nameof(HasTotpValue)
};
private List> _matchDetectionOptions =
@@ -71,6 +74,7 @@ namespace Bit.App.Pages
_collectionService = ServiceContainer.Resolve("collectionService");
_eventService = ServiceContainer.Resolve("eventService");
_policyService = ServiceContainer.Resolve("policyService");
+ _clipboardService = ServiceContainer.Resolve("clipboardService");
GeneratePasswordCommand = new Command(GeneratePassword);
TogglePasswordCommand = new Command(TogglePassword);
@@ -79,6 +83,7 @@ namespace Bit.App.Pages
UriOptionsCommand = new Command(UriOptions);
FieldOptionsCommand = new Command(FieldOptions);
PasswordPromptHelpCommand = new Command(PasswordPromptHelp);
+ CopyCommand = new AsyncCommand(CopyTotpClipboardAsync, onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
Uris = new ExtendedObservableCollection();
Fields = new ExtendedObservableCollection();
Collections = new ExtendedObservableCollection();
@@ -139,6 +144,7 @@ namespace Bit.App.Pages
public Command UriOptionsCommand { get; set; }
public Command FieldOptionsCommand { get; set; }
public Command PasswordPromptHelpCommand { get; set; }
+ public AsyncCommand CopyCommand { get; set; }
public string CipherId { get; set; }
public string OrganizationId { get; set; }
public string FolderId { get; set; }
@@ -284,7 +290,8 @@ namespace Bit.App.Pages
public bool AllowPersonal { get; set; }
public bool PasswordPrompt => Cipher.Reprompt != CipherRepromptType.None;
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;
@@ -818,6 +825,19 @@ namespace Bit.App.Pages
{
TriggerPropertyChanged(nameof(Cipher), AdditionalPropertiesToRaiseOnCipherChanged);
}
+
+ private async Task CopyTotpClipboardAsync()
+ {
+ try
+ {
+ await _clipboardService.CopyTextAsync(Cipher.Login.Totp);
+ _platformUtilsService.ShowToast("info", null, string.Format(AppResources.ValueHasBeenCopied, AppResources.AuthenticatorKeyScanner));
+ }
+ catch (Exception ex)
+ {
+ _logger.Exception(ex);
+ }
+ }
}
public class CipherAddEditPageFieldViewModel : ExtendedViewModel
diff --git a/src/App/Pages/Vault/CipherDetailsPage.xaml b/src/App/Pages/Vault/CipherDetailsPage.xaml
index 53f1ca519..016ee83fc 100644
--- a/src/App/Pages/Vault/CipherDetailsPage.xaml
+++ b/src/App/Pages/Vault/CipherDetailsPage.xaml
@@ -1,5 +1,5 @@
-
-
+
+ x:Key="collectionsItem"
+ x:Name="_collectionsItem"
+ Clicked="Collections_Clicked"
+ Order="Secondary" />
+ x:Key="shareItem"
+ x:Name="_shareItem"
+ Clicked="Share_Clicked"
+ Order="Secondary" />
-
-
@@ -126,7 +126,7 @@
Grid.Column="0"
LineBreakMode="CharacterWrap"
IsVisible="{Binding ShowPassword}" />
-
-
-
+
-
+
-
+
-
+
+
+
@@ -244,7 +265,7 @@
Grid.Row="1"
Grid.Column="0"
IsVisible="{Binding ShowCardNumber}" />
-
-
-
-
+ StyleClass="box-value" />
@@ -590,7 +611,7 @@
StyleClass="box-value"
IsVisible="{Binding ShowHiddenValue, Converter={StaticResource inverseBool}}" />
-
-
-
+
\ No newline at end of file
diff --git a/src/App/Pages/Vault/CipherDetailsPage.xaml.cs b/src/App/Pages/Vault/CipherDetailsPage.xaml.cs
index 5465d601a..52c9cbf7e 100644
--- a/src/App/Pages/Vault/CipherDetailsPage.xaml.cs
+++ b/src/App/Pages/Vault/CipherDetailsPage.xaml.cs
@@ -111,8 +111,8 @@ namespace Bit.App.Pages
{
base.OnDisappearing();
IsBusy = false;
+ _vm.StopCiphersTotpTick().FireAndForget();
_broadcasterService.Unsubscribe(nameof(CipherDetailsPage));
- _vm.CleanUp();
}
private async void PasswordHistory_Tapped(object sender, System.EventArgs e)
diff --git a/src/App/Pages/Vault/CipherDetailsPageViewModel.cs b/src/App/Pages/Vault/CipherDetailsPageViewModel.cs
index 15e238414..e5d172a46 100644
--- a/src/App/Pages/Vault/CipherDetailsPageViewModel.cs
+++ b/src/App/Pages/Vault/CipherDetailsPageViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
using Bit.App.Abstractions;
@@ -21,6 +22,7 @@ namespace Bit.App.Pages
{
private readonly ICipherService _cipherService;
private readonly IStateService _stateService;
+ private readonly IAuditService _auditService;
private readonly ITotpService _totpService;
private readonly IMessagingService _messagingService;
private readonly IEventService _eventService;
@@ -42,11 +44,15 @@ namespace Bit.App.Pages
private byte[] _attachmentData;
private string _attachmentFilename;
private bool _passwordReprompted;
+ private TotpHelper _totpTickHelper;
+ private CancellationTokenSource _totpTickCancellationToken;
+ private Task _totpTickTask;
public CipherDetailsPageViewModel()
{
_cipherService = ServiceContainer.Resolve("cipherService");
_stateService = ServiceContainer.Resolve("stateService");
+ _auditService = ServiceContainer.Resolve("auditService");
_totpService = ServiceContainer.Resolve("totpService");
_messagingService = ServiceContainer.Resolve("messagingService");
_eventService = ServiceContainer.Resolve("eventService");
@@ -91,6 +97,7 @@ namespace Bit.App.Pages
nameof(ShowIdentityAddress),
nameof(IsDeleted),
nameof(CanEdit),
+ nameof(ShowUpgradePremiumTotpText)
};
public List Fields
{
@@ -191,21 +198,22 @@ namespace Bit.App.Pages
return fs;
}
}
+
+ public bool ShowUpgradePremiumTotpText => !CanAccessPremium && ShowTotp;
public bool ShowUris => IsLogin && Cipher.Login.HasUris;
public bool ShowIdentityAddress => IsIdentity && (
!string.IsNullOrWhiteSpace(Cipher.Identity.Address1) ||
!string.IsNullOrWhiteSpace(Cipher.Identity.City) ||
!string.IsNullOrWhiteSpace(Cipher.Identity.Country));
public bool ShowAttachments => Cipher.HasAttachments && (CanAccessPremium || Cipher.OrganizationId != null);
- public bool ShowTotp => IsLogin && !string.IsNullOrWhiteSpace(Cipher.Login.Totp) &&
- !string.IsNullOrWhiteSpace(TotpCodeFormatted);
+ public bool ShowTotp => IsLogin && !string.IsNullOrWhiteSpace(Cipher.Login.Totp);
public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
public string ShowCardNumberIcon => ShowCardNumber ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
public string ShowCardCodeIcon => ShowCardCode ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye;
public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow;
public string TotpCodeFormatted
{
- get => _totpCodeFormatted;
+ get => _canAccessPremium ? _totpCodeFormatted : string.Empty;
set => SetProperty(ref _totpCodeFormatted, value,
additionalPropertyNames: new string[]
{
@@ -215,7 +223,11 @@ namespace Bit.App.Pages
public string TotpSec
{
get => _totpSec;
- set => SetProperty(ref _totpSec, value);
+ set => SetProperty(ref _totpSec, value,
+ additionalPropertyNames: new string[]
+ {
+ nameof(TotpProgress)
+ });
}
public bool TotpLow
{
@@ -226,12 +238,12 @@ namespace Bit.App.Pages
Page.Resources["textTotp"] = ThemeManager.Resources()[value ? "text-danger" : "text-default"];
}
}
+ public double TotpProgress => string.IsNullOrEmpty(TotpSec) ? 0 : double.Parse(TotpSec) * 100 / 30;
public bool IsDeleted => Cipher.IsDeleted;
public bool CanEdit => !Cipher.IsDeleted;
public async Task LoadAsync(Action finishedLoadingAction = null)
{
- CleanUp();
var cipher = await _cipherService.GetAsync(CipherId);
if (cipher == null)
{
@@ -245,19 +257,10 @@ namespace Bit.App.Pages
if (Cipher.Type == Core.Enums.CipherType.Login && !string.IsNullOrWhiteSpace(Cipher.Login.Totp) &&
(Cipher.OrganizationUseTotp || CanAccessPremium))
{
- await TotpUpdateCodeAsync();
- var interval = _totpService.GetTimeInterval(Cipher.Login.Totp);
- await TotpTickAsync(interval);
- _totpInterval = DateTime.UtcNow;
- Device.StartTimer(new TimeSpan(0, 0, 1), () =>
- {
- if (_totpInterval == null)
- {
- return false;
- }
- var task = TotpTickAsync(interval);
- return true;
- });
+ _totpTickHelper = new TotpHelper(Cipher);
+ _totpTickCancellationToken?.Cancel();
+ _totpTickCancellationToken = new CancellationTokenSource();
+ _totpTickTask = new TimerTask(_logger, StartCiphersTotpTick, _totpTickCancellationToken).RunPeriodic();
}
if (_previousCipherId != CipherId)
{
@@ -268,9 +271,27 @@ namespace Bit.App.Pages
return true;
}
- public void CleanUp()
+ private async void StartCiphersTotpTick()
{
- _totpInterval = null;
+ try
+ {
+ await _totpTickHelper.GenerateNewTotpValues();
+ TotpSec = _totpTickHelper.TotpSec;
+ TotpCodeFormatted = _totpTickHelper.TotpCodeFormatted;
+ }
+ catch (Exception ex)
+ {
+ _logger.Exception(ex);
+ }
+ }
+
+ public async Task StopCiphersTotpTick()
+ {
+ _totpTickCancellationToken?.Cancel();
+ if (_totpTickTask != null)
+ {
+ await _totpTickTask;
+ }
}
public async void TogglePassword()
@@ -592,7 +613,7 @@ namespace Bit.App.Pages
}
else if (id == "LoginTotp")
{
- text = _totpCode;
+ text = TotpCodeFormatted.Replace(" ", string.Empty);
name = AppResources.VerificationCodeTotp;
}
else if (id == "LoginUri")
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml
index 0ba3203b3..40496e133 100644
--- a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml
@@ -6,6 +6,7 @@
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:effects="clr-namespace:Bit.App.Effects"
xmlns:controls="clr-namespace:Bit.App.Controls"
+ xmlns:xct="http://xamarin.com/schemas/2020/toolkit"
xmlns:core="clr-namespace:Bit.Core;assembly=BitwardenCore"
x:DataType="pages:GroupingsPageViewModel"
Title="{Binding PageTitle}"
@@ -53,6 +54,14 @@
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}" />
+
+
+
+
@@ -130,7 +140,6 @@
AutomationProperties.IsInAccessibleTree="True"
AutomationProperties.Name="{u:I18n Filter}" />
-
("vaultTimeoutService");
_cipherService = ServiceContainer.Resolve("cipherService");
_deviceActionService = ServiceContainer.Resolve("deviceActionService");
+ _platformUtilsService = ServiceContainer.Resolve("platformUtilsService");
_vm = BindingContext as GroupingsPageViewModel;
_vm.Page = this;
_vm.MainPage = mainPage;
@@ -48,6 +50,7 @@ namespace Bit.App.Pages
_vm.FolderId = folderId;
_vm.CollectionId = collectionId;
_vm.Deleted = deleted;
+ _vm.ShowTotp = showTotp;
_previousPage = previousPage;
if (pageTitle != null)
{
@@ -69,7 +72,7 @@ namespace Bit.App.Pages
ToolbarItems.Add(_lockItem);
ToolbarItems.Add(_exitItem);
}
- if (deleted)
+ if (deleted || showTotp)
{
_absLayout.Children.Remove(_fab);
ToolbarItems.Remove(_addItem);
@@ -189,10 +192,11 @@ namespace Bit.App.Pages
return false;
}
- protected override void OnDisappearing()
+ protected override async void OnDisappearing()
{
base.OnDisappearing();
IsBusy = false;
+ _vm.StopCiphersTotpTick().FireAndForget();
_broadcasterService.Unsubscribe(_pageName);
_vm.DisableRefreshing();
_accountAvatar?.OnDisappearing();
@@ -200,35 +204,54 @@ namespace Bit.App.Pages
private async void RowSelected(object sender, SelectionChangedEventArgs e)
{
- ((ExtendedCollectionView)sender).SelectedItem = null;
- if (!DoOnce())
+ try
{
- return;
- }
- if (!(e.CurrentSelection?.FirstOrDefault() is GroupingsPageListItem item))
- {
- return;
- }
+ ((ExtendedCollectionView)sender).SelectedItem = null;
+ if (!DoOnce())
+ {
+ return;
+ }
- if (item.IsTrash)
- {
- await _vm.SelectTrashAsync();
+ if (e.CurrentSelection?.FirstOrDefault() is GroupingsPageTOTPListItem totpItem)
+ {
+ await _vm.SelectCipherAsync(totpItem.Cipher);
+ return;
+ }
+
+ if (!(e.CurrentSelection?.FirstOrDefault() is GroupingsPageListItem item))
+ {
+ return;
+ }
+
+ if (item.IsTrash)
+ {
+ await _vm.SelectTrashAsync();
+ }
+ else if (item.IsTotpCode)
+ {
+ await _vm.SelectTotpCodesAsync();
+ }
+ else if (item.Cipher != null)
+ {
+ await _vm.SelectCipherAsync(item.Cipher);
+ }
+ else if (item.Folder != null)
+ {
+ await _vm.SelectFolderAsync(item.Folder);
+ }
+ else if (item.Collection != null)
+ {
+ await _vm.SelectCollectionAsync(item.Collection);
+ }
+ else if (item.Type != null)
+ {
+ await _vm.SelectTypeAsync(item.Type.Value);
+ }
}
- else if (item.Cipher != null)
+ catch (Exception ex)
{
- await _vm.SelectCipherAsync(item.Cipher);
- }
- else if (item.Folder != null)
- {
- await _vm.SelectFolderAsync(item.Folder);
- }
- else if (item.Collection != null)
- {
- await _vm.SelectCollectionAsync(item.Collection);
- }
- else if (item.Type != null)
- {
- await _vm.SelectTypeAsync(item.Type.Value);
+ LoggerHelper.LogEvenIfCantBeResolved(ex);
+ _platformUtilsService.ShowDialogAsync(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok).FireAndForget();
}
}
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs
index 1702aa85f..bb059c989 100644
--- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListGroup.cs
@@ -2,13 +2,13 @@
namespace Bit.App.Pages
{
- public class GroupingsPageListGroup : List
+ public class GroupingsPageListGroup : List
{
public GroupingsPageListGroup(string name, int count, bool doUpper = true, bool first = false)
- : this(new List(), name, count, doUpper, first)
+ : this(new List(), name, count, doUpper, first)
{ }
- public GroupingsPageListGroup(List groupItems, string name, int count,
+ public GroupingsPageListGroup(IEnumerable groupItems, string name, int count,
bool doUpper = true, bool first = false)
{
AddRange(groupItems);
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs
index 34674d961..2df0350af 100644
--- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItem.cs
@@ -17,6 +17,7 @@ namespace Bit.App.Pages
public string ItemCount { get; set; }
public bool FuzzyAutofill { get; set; }
public bool IsTrash { get; set; }
+ public bool IsTotpCode { get; set; }
public string Name
{
@@ -38,6 +39,10 @@ namespace Bit.App.Pages
{
_name = Collection.Name;
}
+ else if (IsTotpCode)
+ {
+ _name = AppResources.VerificationCodes;
+ }
else if (Type != null)
{
switch (Type.Value)
@@ -82,6 +87,10 @@ namespace Bit.App.Pages
{
_icon = BitwardenIcons.Collection;
}
+ else if (IsTotpCode)
+ {
+ _icon = BitwardenIcons.Clock;
+ }
else if (Type != null)
{
switch (Type.Value)
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs
index a2e2207b4..d05a1cfb6 100644
--- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs
@@ -7,6 +7,7 @@ namespace Bit.App.Pages
public DataTemplate HeaderTemplate { get; set; }
public DataTemplate CipherTemplate { get; set; }
public DataTemplate GroupTemplate { get; set; }
+ public DataTemplate AuthenticatorTemplate { get; set; }
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
{
@@ -15,10 +16,16 @@ namespace Bit.App.Pages
return HeaderTemplate;
}
+ if (item is GroupingsPageTOTPListItem)
+ {
+ return AuthenticatorTemplate;
+ }
+
if (item is GroupingsPageListItem listItem)
{
return listItem.Cipher != null ? CipherTemplate : GroupTemplate;
}
+
return null;
}
}
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageTOTPListItem.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageTOTPListItem.cs
new file mode 100644
index 000000000..79149130e
--- /dev/null
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageTOTPListItem.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Threading.Tasks;
+using Bit.App.Resources;
+using Bit.App.Utilities;
+using Bit.Core.Abstractions;
+using Bit.Core.Models.View;
+using Bit.Core.Utilities;
+using Xamarin.CommunityToolkit.ObjectModel;
+using Xamarin.Essentials;
+using Xamarin.Forms;
+
+namespace Bit.App.Pages
+{
+ public class GroupingsPageTOTPListItem : ExtendedViewModel, IGroupingsPageListItem
+ {
+ private readonly LazyResolve _logger = new LazyResolve("logger");
+ private readonly ITotpService _totpService;
+ private readonly IPlatformUtilsService _platformUtilsService;
+ private readonly IClipboardService _clipboardService;
+ private CipherView _cipher;
+
+ private bool _websiteIconsEnabled;
+ private string _iconImageSource = string.Empty;
+
+ public int interval { get; set; }
+ private double _progress;
+ private string _totpSec;
+ private string _totpCodeFormatted;
+ private TotpHelper _totpTickHelper;
+
+
+ public GroupingsPageTOTPListItem(CipherView cipherView, bool websiteIconsEnabled)
+ {
+ _totpService = ServiceContainer.Resolve("totpService");
+ _platformUtilsService = ServiceContainer.Resolve("platformUtilsService");
+ _clipboardService = ServiceContainer.Resolve("clipboardService");
+
+ Cipher = cipherView;
+ WebsiteIconsEnabled = websiteIconsEnabled;
+ interval = _totpService.GetTimeInterval(Cipher.Login.Totp);
+ CopyCommand = new AsyncCommand(CopyToClipboardAsync,
+ onException: ex => _logger.Value.Exception(ex),
+ allowsMultipleExecutions: false);
+ _totpTickHelper = new TotpHelper(cipherView);
+ }
+
+ public AsyncCommand CopyCommand { get; set; }
+
+ public CipherView Cipher
+ {
+ get => _cipher;
+ set => SetProperty(ref _cipher, value);
+ }
+
+ public string TotpCodeFormatted
+ {
+ get => _totpCodeFormatted;
+ set => SetProperty(ref _totpCodeFormatted, value,
+ additionalPropertyNames: new string[]
+ {
+ nameof(TotpCodeFormattedStart),
+ nameof(TotpCodeFormattedEnd),
+ });
+ }
+
+ public string TotpSec
+ {
+ get => _totpSec;
+ set => SetProperty(ref _totpSec, value);
+ }
+ public double Progress
+ {
+ get => _progress;
+ set => SetProperty(ref _progress, value);
+ }
+ public bool WebsiteIconsEnabled
+ {
+ get => _websiteIconsEnabled;
+ set => SetProperty(ref _websiteIconsEnabled, value);
+ }
+
+ public bool ShowIconImage
+ {
+ get => WebsiteIconsEnabled
+ && !string.IsNullOrWhiteSpace(Cipher.Login?.Uri)
+ && IconImageSource != null;
+ }
+
+ public string IconImageSource
+ {
+ get
+ {
+ if (_iconImageSource == string.Empty) // default value since icon source can return null
+ {
+ _iconImageSource = IconImageHelper.GetLoginIconImage(Cipher);
+ }
+ return _iconImageSource;
+ }
+
+ }
+
+ public string TotpCodeFormattedStart => TotpCodeFormatted?.Split(' ')[0];
+
+ public string TotpCodeFormattedEnd => TotpCodeFormatted?.Split(' ')[1];
+
+ public async Task CopyToClipboardAsync()
+ {
+ await _clipboardService.CopyTextAsync(TotpCodeFormatted);
+ _platformUtilsService.ShowToast("info", null, string.Format(AppResources.ValueHasBeenCopied, AppResources.VerificationCodeTotp));
+ }
+
+ public async Task TotpTickAsync()
+ {
+ await _totpTickHelper.GenerateNewTotpValues();
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ TotpSec = _totpTickHelper.TotpSec;
+ Progress = _totpTickHelper.Progress;
+ TotpCodeFormatted = _totpTickHelper.TotpCodeFormatted;
+ });
+ }
+ }
+}
diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
index 87f7d6212..006694b3c 100644
--- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
+++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
@@ -1,7 +1,9 @@
using System;
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.Controls;
using Bit.App.Resources;
@@ -29,13 +31,16 @@ namespace Bit.App.Pages
private bool _showList;
private bool _websiteIconsEnabled;
private bool _syncRefreshing;
+ private bool _showTotpFilter;
+ private bool _totpFilterEnable;
private string _noDataText;
private List _allCiphers;
private Dictionary _folderCounts = new Dictionary();
private Dictionary _collectionCounts = new Dictionary();
private Dictionary _typeCounts = new Dictionary();
private int _deletedCount = 0;
-
+ private CancellationTokenSource _totpTickCts;
+ private Task _totpTickTask;
private readonly ICipherService _cipherService;
private readonly IFolderService _folderService;
private readonly ICollectionService _collectionService;
@@ -74,6 +79,9 @@ namespace Bit.App.Pages
await LoadAsync();
});
CipherOptionsCommand = new Command(CipherOptionsAsync);
+ VaultFilterCommand = new AsyncCommand(VaultFilterOptionsAsync,
+ onException: ex => _logger.Exception(ex),
+ allowsMultipleExecutions: false);
AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger)
{
@@ -94,6 +102,7 @@ namespace Bit.App.Pages
&& NoFolderCiphers.Count < NoFolderListSize
&& (Collections is null || !Collections.Any());
public List Ciphers { get; set; }
+ public List TOTPCiphers { get; set; }
public List FavoriteCiphers { get; set; }
public List NoFolderCiphers { get; set; }
public List Folders { get; set; }
@@ -151,9 +160,12 @@ namespace Bit.App.Pages
get => _websiteIconsEnabled;
set => SetProperty(ref _websiteIconsEnabled, value);
}
-
+ public bool ShowTotp
+ {
+ get => _showTotpFilter;
+ set => SetProperty(ref _showTotpFilter, value);
+ }
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
-
public ObservableRangeCollection GroupedItems { get; set; }
public Command RefreshCommand { get; set; }
public Command CipherOptionsCommand { get; set; }
@@ -188,13 +200,14 @@ namespace Bit.App.Pages
{
PageTitle = ShowVaultFilter ? AppResources.Vaults : AppResources.MyVault;
}
-
+ var canAccessPremium = await _stateService.CanAccessPremiumAsync();
_doingLoad = true;
LoadedOnce = true;
ShowNoData = false;
Loading = true;
ShowList = false;
ShowAddCipherButton = !Deleted;
+
var groupedItems = new List();
var page = Page as GroupingsPage;
@@ -218,6 +231,8 @@ namespace Bit.App.Pages
}
if (MainPage)
{
+ AddTotpGroupItem(canAccessPremium, groupedItems, uppercaseGroupNames);
+
groupedItems.Add(new GroupingsPageListGroup(
AppResources.Types, 4, uppercaseGroupNames, !hasFavorites)
{
@@ -274,10 +289,12 @@ namespace Bit.App.Pages
}
if (Ciphers?.Any() ?? false)
{
- var ciphersListItems = Ciphers.Where(c => c.IsDeleted == Deleted)
- .Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
- groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
- ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
+ CreateCipherGroupedItems(groupedItems);
+ }
+ if (ShowTotp && (!TOTPCiphers?.Any() ?? false))
+ {
+ Page.Navigation.PopAsync();
+ return;
}
if (ShowNoFolderCipherGroup)
{
@@ -365,6 +382,60 @@ namespace Bit.App.Pages
}
}
+ private void AddTotpGroupItem(bool canAccessPremium, List groupedItems, bool uppercaseGroupNames)
+ {
+ if (canAccessPremium && TOTPCiphers?.Any() == true)
+ {
+ groupedItems.Insert(0, new GroupingsPageListGroup(
+ AppResources.Totp, 1, uppercaseGroupNames, false)
+ {
+ new GroupingsPageListItem
+ {
+ IsTotpCode = true,
+ Type = CipherType.Login,
+ ItemCount = TOTPCiphers.Count().ToString("N0")
+ }
+ });
+ }
+ }
+
+ private void CreateCipherGroupedItems(List groupedItems)
+ {
+ var uppercaseGroupNames = _deviceActionService.DeviceType == DeviceType.iOS;
+ _totpTickCts?.Cancel();
+ if (ShowTotp)
+ {
+ var ciphersListItems = TOTPCiphers.Select(c => new GroupingsPageTOTPListItem(c, true)).ToList();
+ groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
+ ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
+
+ StartCiphersTotpTick(ciphersListItems);
+ }
+ else
+ {
+ var ciphersListItems = Ciphers.Where(c => c.IsDeleted == Deleted)
+ .Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
+ groupedItems.Add(new GroupingsPageListGroup(ciphersListItems, AppResources.Items,
+ ciphersListItems.Count, uppercaseGroupNames, !MainPage && !groupedItems.Any()));
+ }
+ }
+
+ private void StartCiphersTotpTick(List ciphersListItems)
+ {
+ _totpTickCts?.Cancel();
+ _totpTickCts = new CancellationTokenSource();
+ _totpTickTask = new TimerTask(logger, () => ciphersListItems.ForEach(i => i.TotpTickAsync()), _totpTickCts).RunPeriodic();
+ }
+
+ public async Task StopCiphersTotpTick()
+ {
+ _totpTickCts?.Cancel();
+ if (_totpTickTask != null)
+ {
+ await _totpTickTask;
+ }
+ }
+
public void DisableRefreshing()
{
Refreshing = false;
@@ -425,6 +496,13 @@ namespace Bit.App.Pages
await Page.Navigation.PushAsync(page);
}
+ public async Task SelectTotpCodesAsync()
+ {
+ var page = new GroupingsPage(false, CipherType.Login, null, null, AppResources.VerificationCodes, _vaultFilterSelection, null,
+ false, true);
+ await Page.Navigation.PushAsync(page);
+ }
+
public async Task ExitAsync()
{
var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.ExitConfirmation,
@@ -462,6 +540,7 @@ namespace Bit.App.Pages
NoDataText = AppResources.NoItems;
_allCiphers = await GetAllCiphersAsync();
HasCiphers = _allCiphers.Any();
+ TOTPCiphers = _allCiphers.Where(c => c.IsDeleted == Deleted && c.Type == CipherType.Login && !string.IsNullOrEmpty(c.Login?.Totp)).ToList();
FavoriteCiphers?.Clear();
NoFolderCiphers?.Clear();
_folderCounts.Clear();
@@ -487,6 +566,10 @@ namespace Bit.App.Pages
Filter = c => c.IsDeleted;
NoDataText = AppResources.NoItemsTrash;
}
+ else if (ShowTotp)
+ {
+ Filter = c => c.Type == CipherType.Login && !c.IsDeleted && !string.IsNullOrEmpty(c.Login?.Totp);
+ }
else if (Type != null)
{
Filter = c => c.Type == Type.Value && !c.IsDeleted;
diff --git a/src/App/Pages/Vault/ScanPage.xaml b/src/App/Pages/Vault/ScanPage.xaml
index d90c5a2b7..9c1b31346 100644
--- a/src/App/Pages/Vault/ScanPage.xaml
+++ b/src/App/Pages/Vault/ScanPage.xaml
@@ -1,13 +1,26 @@
-
-
+
+ Title="{Binding ScanQrPageTitle}">
+
+
+
+
+
+
+
+
+
+
@@ -16,67 +29,114 @@
-
+
+
+
+
+
-
-
-
+
-
-
-
-
-
+ IsVisible="{Binding ShowScanner}"
+ Grid.Column="0"
+ Grid.Row="0"
+ Grid.RowSpan="2"
+ Margin="30,0">
-
-
-
+ PaintSurface="OnCanvasViewPaintSurface"/>
-
+
+
+
-
-
+
+
+
+
+
+
-
+
+
+
-
-
+ Margin="0,15"
+ StyleClass="text-sm"
+ FontAttributes="Bold"
+ VerticalOptions="End"
+ HorizontalOptions="Center" >
+
+
+
+
-
-
+
\ No newline at end of file
diff --git a/src/App/Pages/Vault/ScanPage.xaml.cs b/src/App/Pages/Vault/ScanPage.xaml.cs
index d0a149621..1927e2587 100644
--- a/src/App/Pages/Vault/ScanPage.xaml.cs
+++ b/src/App/Pages/Vault/ScanPage.xaml.cs
@@ -1,19 +1,31 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
+using Bit.App.Utilities;
using Bit.Core.Abstractions;
using Bit.Core.Utilities;
+using SkiaSharp;
+using SkiaSharp.Views.Forms;
+using Xamarin.Essentials;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public partial class ScanPage : BaseContentPage
{
+ private ScanPageViewModel ViewModel => BindingContext as ScanPageViewModel;
private readonly Action _callback;
-
private CancellationTokenSource _autofocusCts;
private Task _continuousAutofocusTask;
+ private readonly Color _greenColor;
+ private readonly SKColor _blueSKColor;
+ private readonly SKColor _greenSKColor;
+ private readonly Stopwatch _stopwatch;
+ private bool _pageIsActive;
+ private bool _qrcodeFound;
+ private float _scale;
private readonly LazyResolve _logger = new LazyResolve("logger");
@@ -32,6 +44,12 @@ namespace Bit.App.Pages
{
ToolbarItems.RemoveAt(0);
}
+
+ _greenColor = ThemeManager.GetResourceColor("SuccessColor");
+ _greenSKColor = _greenColor.ToSKColor();
+ _blueSKColor = ThemeManager.GetResourceColor("PrimaryColor").ToSKColor();
+ _stopwatch = new Stopwatch();
+ _qrcodeFound = false;
}
protected override void OnAppearing()
@@ -58,7 +76,14 @@ namespace Bit.App.Pages
{
if (!autofocusCts.IsCancellationRequested)
{
- _zxing.AutoFocus();
+ try
+ {
+ _zxing.AutoFocus();
+ }
+ catch (Exception ex)
+ {
+ _logger.Value.Exception(ex);
+ }
}
});
}
@@ -69,27 +94,83 @@ namespace Bit.App.Pages
_logger.Value.Exception(ex);
}
}, autofocusCts.Token);
+ _pageIsActive = true;
+ AnimationLoopAsync();
}
protected override async void OnDisappearing()
{
_autofocusCts?.Cancel();
-
if (_continuousAutofocusTask != null)
{
await _continuousAutofocusTask;
}
_zxing.IsScanning = false;
-
+ _pageIsActive = false;
base.OnDisappearing();
}
- private void OnScanResult(ZXing.Result result)
+ private async void OnScanResult(ZXing.Result result)
{
- // Stop analysis until we navigate away so we don't keep reading barcodes
- _zxing.IsAnalyzing = false;
+ try
+ {
+ // Stop analysis until we navigate away so we don't keep reading barcodes
+ _zxing.IsAnalyzing = false;
+ var text = result?.Text;
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ if (text.StartsWith("otpauth://totp"))
+ {
+ await QrCodeFoundAsync();
+ _callback(text);
+ return;
+ }
+ else if (Uri.TryCreate(text, UriKind.Absolute, out Uri uri) &&
+ !string.IsNullOrWhiteSpace(uri?.Query))
+ {
+ var queryParts = uri.Query.Substring(1).ToLowerInvariant().Split('&');
+ foreach (var part in queryParts)
+ {
+ if (part.StartsWith("secret="))
+ {
+ await QrCodeFoundAsync();
+ var subResult = part.Substring(7);
+ if (!string.IsNullOrEmpty(subResult))
+ {
+ _callback(subResult.ToUpperInvariant());
+ }
+ return;
+ }
+ }
+ }
+ }
+ _callback(null);
+ }
+ catch (Exception ex)
+ {
+ _logger?.Value?.Exception(ex);
+ }
+ }
+
+ private async Task QrCodeFoundAsync()
+ {
+ _qrcodeFound = true;
+ Vibration.Vibrate();
+ await Task.Delay(1000);
_zxing.IsScanning = false;
- var text = result?.Text;
+ }
+
+ private async void Close_Clicked(object sender, System.EventArgs e)
+ {
+ if (DoOnce())
+ {
+ await Navigation.PopModalAsync();
+ }
+ }
+
+ private void AddAuthenticationKey_OnClicked(object sender, EventArgs e)
+ {
+ var text = ViewModel.TotpAuthenticationKey;
if (!string.IsNullOrWhiteSpace(text))
{
if (text.StartsWith("otpauth://totp"))
@@ -98,7 +179,7 @@ namespace Bit.App.Pages
return;
}
else if (Uri.TryCreate(text, UriKind.Absolute, out Uri uri) &&
- !string.IsNullOrWhiteSpace(uri?.Query))
+ !string.IsNullOrWhiteSpace(uri?.Query))
{
var queryParts = uri.Query.Substring(1).ToLowerInvariant().Split('&');
foreach (var part in queryParts)
@@ -114,11 +195,77 @@ namespace Bit.App.Pages
_callback(null);
}
- private async void Close_Clicked(object sender, System.EventArgs e)
+ private void ToggleScanMode_OnTapped(object sender, EventArgs e)
{
- if (DoOnce())
+ ViewModel.ToggleScanModeCommand.Execute(null);
+ if (!ViewModel.ShowScanner)
{
- await Navigation.PopModalAsync();
+ _authenticationKeyEntry.Focus();
+ }
+ }
+
+ private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
+ {
+ var info = args.Info;
+ var surface = args.Surface;
+ var canvas = surface.Canvas;
+ var margins = 20;
+ var maxSquareSize = (Math.Min(info.Height, info.Width) * 0.9f - margins) * _scale;
+ var squareSize = maxSquareSize;
+ var lineSize = squareSize * 0.15f;
+ var startXPoint = (info.Width / 2) - (squareSize / 2);
+ var startYPoint = (info.Height / 2) - (squareSize / 2);
+ canvas.Clear(SKColors.Transparent);
+
+ using (var strokePaint = new SKPaint
+ {
+ Color = _qrcodeFound ? _greenSKColor : _blueSKColor,
+ StrokeWidth = 9 * _scale,
+ StrokeCap = SKStrokeCap.Round,
+ })
+ {
+ canvas.Scale(1, 1);
+ //top left
+ canvas.DrawLine(startXPoint, startYPoint, startXPoint, startYPoint + lineSize, strokePaint);
+ canvas.DrawLine(startXPoint, startYPoint, startXPoint + lineSize, startYPoint, strokePaint);
+ //bot left
+ canvas.DrawLine(startXPoint, startYPoint + squareSize, startXPoint, startYPoint + squareSize - lineSize, strokePaint);
+ canvas.DrawLine(startXPoint, startYPoint + squareSize, startXPoint + lineSize, startYPoint + squareSize, strokePaint);
+ //top right
+ canvas.DrawLine(startXPoint + squareSize, startYPoint, startXPoint + squareSize - lineSize, startYPoint, strokePaint);
+ canvas.DrawLine(startXPoint + squareSize, startYPoint, startXPoint + squareSize, startYPoint + lineSize, strokePaint);
+ //bot right
+ canvas.DrawLine(startXPoint + squareSize, startYPoint + squareSize, startXPoint + squareSize - lineSize, startYPoint + squareSize, strokePaint);
+ canvas.DrawLine(startXPoint + squareSize, startYPoint + squareSize, startXPoint + squareSize, startYPoint + squareSize - lineSize, strokePaint);
+ }
+ }
+
+ async Task AnimationLoopAsync()
+ {
+ try
+ {
+ _stopwatch.Start();
+ while (_pageIsActive)
+ {
+ var t = _stopwatch.Elapsed.TotalSeconds % 2 / 2;
+ _scale = (20 - (1 - (float)Math.Sin(4 * Math.PI * t))) / 20;
+ SkCanvasView.InvalidateSurface();
+ await Task.Delay(TimeSpan.FromSeconds(1.0 / 30));
+ if (_qrcodeFound && _scale > 0.98f)
+ {
+ _checkIcon.TextColor = _greenColor;
+ SkCanvasView.InvalidateSurface();
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Value?.Exception(ex);
+ }
+ finally
+ {
+ _stopwatch?.Stop();
}
}
}
diff --git a/src/App/Pages/Vault/ScanPageViewModel.cs b/src/App/Pages/Vault/ScanPageViewModel.cs
new file mode 100644
index 000000000..00405756b
--- /dev/null
+++ b/src/App/Pages/Vault/ScanPageViewModel.cs
@@ -0,0 +1,61 @@
+using Bit.App.Resources;
+using Bit.App.Utilities;
+using Bit.Core.Abstractions;
+using Xamarin.Forms;
+
+namespace Bit.App.Pages
+{
+ public class ScanPageViewModel : BaseViewModel
+ {
+ private bool _showScanner = true;
+ private string _totpAuthenticationKey;
+
+ public ScanPageViewModel()
+ {
+ ToggleScanModeCommand = new Command(() => ShowScanner = !ShowScanner);
+ }
+
+ public Command ToggleScanModeCommand { get; set; }
+ public string ScanQrPageTitle => ShowScanner ? AppResources.ScanQrTitle : AppResources.AuthenticatorKeyScanner;
+ public string CameraInstructionTop => ShowScanner ? AppResources.PointYourCameraAtTheQRCode : AppResources.OnceTheKeyIsSuccessfullyEntered;
+ public string TotpAuthenticationKey
+ {
+ get => _totpAuthenticationKey;
+ set => SetProperty(ref _totpAuthenticationKey, value,
+ additionalPropertyNames: new string[]
+ {
+ nameof(ToggleScanModeLabel)
+ });
+ }
+ public bool ShowScanner
+ {
+ get => _showScanner;
+ set => SetProperty(ref _showScanner, value,
+ additionalPropertyNames: new string[]
+ {
+ nameof(ToggleScanModeLabel),
+ nameof(ScanQrPageTitle),
+ nameof(CameraInstructionTop)
+ });
+ }
+
+ public FormattedString ToggleScanModeLabel
+ {
+ get
+ {
+ var fs = new FormattedString();
+ fs.Spans.Add(new Span
+ {
+ Text = ShowScanner ? AppResources.CannotScanQRCode : AppResources.CannotAddAuthenticatorKey,
+ TextColor = ThemeManager.GetResourceColor("TitleTextColor")
+ });
+ fs.Spans.Add(new Span
+ {
+ Text = ShowScanner ? AppResources.EnterKeyManually : AppResources.ScanQRCode,
+ TextColor = ThemeManager.GetResourceColor("ScanningToggleModeTextColor")
+ });
+ return fs;
+ }
+ }
+ }
+}
diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs
index 13728fd05..437781261 100644
--- a/src/App/Resources/AppResources.Designer.cs
+++ b/src/App/Resources/AppResources.Designer.cs
@@ -353,6 +353,12 @@ namespace Bit.App.Resources {
}
}
+ public static string Authenticator {
+ get {
+ return ResourceManager.GetString("Authenticator", resourceCulture);
+ }
+ }
+
public static string Name {
get {
return ResourceManager.GetString("Name", resourceCulture);
@@ -1457,15 +1463,9 @@ namespace Bit.App.Resources {
}
}
- public static string CameraInstructionBottom {
+ public static string PointYourCameraAtTheQRCode {
get {
- return ResourceManager.GetString("CameraInstructionBottom", resourceCulture);
- }
- }
-
- public static string CameraInstructionTop {
- get {
- return ResourceManager.GetString("CameraInstructionTop", resourceCulture);
+ return ResourceManager.GetString("PointYourCameraAtTheQRCode", resourceCulture);
}
}
@@ -4049,6 +4049,78 @@ namespace Bit.App.Resources {
}
}
+ public static string Totp {
+ get {
+ return ResourceManager.GetString("Totp", resourceCulture);
+ }
+ }
+
+ public static string VerificationCodes {
+ get {
+ return ResourceManager.GetString("VerificationCodes", resourceCulture);
+ }
+ }
+
+ public static string PremiumSubscriptionRequired {
+ get {
+ return ResourceManager.GetString("PremiumSubscriptionRequired", resourceCulture);
+ }
+ }
+
+ public static string CannotAddAuthenticatorKey {
+ get {
+ return ResourceManager.GetString("CannotAddAuthenticatorKey", resourceCulture);
+ }
+ }
+
+ public static string ScanQRCode {
+ get {
+ return ResourceManager.GetString("ScanQRCode", resourceCulture);
+ }
+ }
+
+ public static string CannotScanQRCode {
+ get {
+ return ResourceManager.GetString("CannotScanQRCode", resourceCulture);
+ }
+ }
+
+ public static string AuthenticatorKeyScanner {
+ get {
+ return ResourceManager.GetString("AuthenticatorKeyScanner", resourceCulture);
+ }
+ }
+
+ public static string EnterKeyManually {
+ get {
+ return ResourceManager.GetString("EnterKeyManually", resourceCulture);
+ }
+ }
+
+ public static string AddTotp {
+ get {
+ return ResourceManager.GetString("AddTotp", resourceCulture);
+ }
+ }
+
+ public static string SetupTotp {
+ get {
+ return ResourceManager.GetString("SetupTotp", resourceCulture);
+ }
+ }
+
+ public static string OnceTheKeyIsSuccessfullyEntered {
+ get {
+ return ResourceManager.GetString("OnceTheKeyIsSuccessfullyEntered", resourceCulture);
+ }
+ }
+
+ public static string SelectAddTotpToStoreTheKeySafely {
+ get {
+ return ResourceManager.GetString("SelectAddTotpToStoreTheKeySafely", resourceCulture);
+ }
+ }
+
public static string NeverLockWarning {
get {
return ResourceManager.GetString("NeverLockWarning", resourceCulture);
diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx
index 6c8a69ce9..599b5df13 100644
--- a/src/App/Resources/AppResources.resx
+++ b/src/App/Resources/AppResources.resx
@@ -299,6 +299,10 @@
My Vault
The title for the vault page.
+
+ Authenticator
+ Authenticator TOTP feature
+
Name
Label for an entity name.
@@ -895,11 +899,9 @@
Cannot read authenticator key.
-
- Scanning will happen automatically.
-
-
- Point your camera at the QR code.
+
+ Point your camera at the QR Code.
+Scanning will happen automatically.
Scan QR Code
@@ -2260,6 +2262,43 @@
All
+
+ TOTP
+
+
+ Verification Codes
+
+
+ Premium subscription required
+
+
+ Cannot add authenticator key?
+
+
+ Scan QR Code
+
+
+ Cannot scan QR Code?
+
+
+ Authenticator Key
+
+
+ Enter Key Manually
+
+
+ Add TOTP
+
+
+ Set up TOTP
+
+
+ Once the key is successfully entered,
+select Add TOTP to store the key safely
+
+
+
+
Setting your lock options to “Never” keeps your vault available to anyone with access to your device. If you use this option, you should ensure that you keep your device properly protected.
diff --git a/src/App/Styles/Android.xaml b/src/App/Styles/Android.xaml
index 42c292738..243f42b7e 100644
--- a/src/App/Styles/Android.xaml
+++ b/src/App/Styles/Android.xaml
@@ -79,9 +79,34 @@
+
-
+
+
diff --git a/src/App/Styles/Black.xaml b/src/App/Styles/Black.xaml
index 42a508ba6..7c64d5ea3 100644
--- a/src/App/Styles/Black.xaml
+++ b/src/App/Styles/Black.xaml
@@ -71,4 +71,6 @@
#ffffff
#52bdfb
+
+ #80BDFF
diff --git a/src/App/Styles/Dark.xaml b/src/App/Styles/Dark.xaml
index 3e585d748..778a2b5a1 100644
--- a/src/App/Styles/Dark.xaml
+++ b/src/App/Styles/Dark.xaml
@@ -71,4 +71,6 @@
#ffffff
#52bdfb
+
+ #80BDFF
diff --git a/src/App/Styles/Light.xaml b/src/App/Styles/Light.xaml
index a4debd1d3..fef760bf0 100644
--- a/src/App/Styles/Light.xaml
+++ b/src/App/Styles/Light.xaml
@@ -71,4 +71,6 @@
#ffffff
#175DDC
+
+ #80BDFF
diff --git a/src/App/Styles/Nord.xaml b/src/App/Styles/Nord.xaml
index 6fde2c1f3..4b83815e1 100644
--- a/src/App/Styles/Nord.xaml
+++ b/src/App/Styles/Nord.xaml
@@ -71,4 +71,6 @@
#e5e9f0
#81a1c1
+
+ #80BDFF
diff --git a/src/App/Styles/iOS.xaml b/src/App/Styles/iOS.xaml
index ca5201346..f6bd66a4c 100644
--- a/src/App/Styles/iOS.xaml
+++ b/src/App/Styles/iOS.xaml
@@ -92,6 +92,32 @@
+
diff --git a/src/App/Utilities/TimerTask.cs b/src/App/Utilities/TimerTask.cs
new file mode 100644
index 000000000..a982d39b0
--- /dev/null
+++ b/src/App/Utilities/TimerTask.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Bit.Core.Abstractions;
+using Xamarin.Forms;
+
+namespace Bit.App.Utilities
+{
+ public class TimerTask
+ {
+ private readonly ILogger _logger;
+ private readonly Action _action;
+ private readonly CancellationTokenSource _cancellationToken;
+
+ public TimerTask(ILogger logger, Action action, CancellationTokenSource cancellationToken)
+ {
+ _logger = logger;
+ _action = action ?? throw new ArgumentNullException();
+ _cancellationToken = cancellationToken;
+ }
+
+ public Task RunPeriodic(TimeSpan? interval = null)
+ {
+ interval = interval ?? TimeSpan.FromSeconds(1);
+ return Task.Run(async () =>
+ {
+ try
+ {
+ while (!_cancellationToken.IsCancellationRequested)
+ {
+ await Device.InvokeOnMainThreadAsync(() =>
+ {
+ if (!_cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ _action();
+ }
+ catch (Exception ex)
+ {
+ _logger?.Exception(ex);
+ }
+ }
+ });
+ await Task.Delay(interval.Value, _cancellationToken.Token);
+ }
+ }
+ catch (TaskCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger?.Exception(ex);
+ }
+ }, _cancellationToken.Token);
+ }
+ }
+}
diff --git a/src/App/Utilities/TotpHelper.cs b/src/App/Utilities/TotpHelper.cs
new file mode 100644
index 000000000..64fc2c7a4
--- /dev/null
+++ b/src/App/Utilities/TotpHelper.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Threading.Tasks;
+using Bit.Core.Abstractions;
+using Bit.Core.Models.View;
+using Bit.Core.Utilities;
+
+namespace Bit.App.Utilities
+{
+ public class TotpHelper
+ {
+ private ITotpService _totpService;
+ private CipherView _cipher;
+ private int _interval;
+
+ public TotpHelper(CipherView cipher)
+ {
+ _totpService = ServiceContainer.Resolve("totpService");
+ _cipher = cipher;
+ _interval = _totpService.GetTimeInterval(cipher?.Login?.Totp);
+ }
+
+ public string TotpSec { get; private set; }
+ public string TotpCodeFormatted { get; private set; }
+ public double Progress { get; private set; }
+
+ public async Task GenerateNewTotpValues()
+ {
+ var epoc = CoreHelpers.EpocUtcNow() / 1000;
+ var mod = epoc % _interval;
+ var totpSec = _interval - mod;
+ TotpSec = totpSec.ToString();
+ Progress = totpSec * 100 / 30;
+ if (mod == 0 || string.IsNullOrEmpty(TotpCodeFormatted))
+ {
+ TotpCodeFormatted = await TotpUpdateCodeAsync();
+ }
+ }
+
+ private async Task TotpUpdateCodeAsync()
+ {
+ var totpCode = await _totpService.GetCodeAsync(_cipher?.Login?.Totp);
+ if (totpCode == null)
+ {
+ return null;
+ }
+
+ if (totpCode.Length <= 4)
+ {
+ return totpCode;
+ }
+
+ var half = (int)Math.Floor(totpCode.Length / 2M);
+ return string.Format("{0} {1}", totpCode.Substring(0, half),
+ totpCode.Substring(half));
+ }
+ }
+}