diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffd331900..a47d79835 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,7 @@ jobs: android: name: Android - runs-on: windows-2019 + runs-on: windows-2022 needs: setup steps: - name: Setup NuGet @@ -68,6 +68,26 @@ jobs: - name: Set up MSBuild uses: microsoft/setup-msbuild@ab534842b4bdf384b8aaf93765dc6f721d9f5fab + - name: Work Around for broken Windows 2022 Runner Image + run: | + Set-Location "C:\Program Files (x86)\Microsoft Visual Studio\Installer\" + $InstallPath = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" + $componentsToAdd = @( + "Component.Xamarin" + ) + [string]$workloadArgs = $componentsToAdd | ForEach-Object {" --add " + $_} + $Arguments = ('/c', "vs_installer.exe", 'modify', '--installPath', "`"$InstallPath`"",$workloadArgs, '--quiet', '--norestart', '--nocache') + $process = Start-Process -FilePath cmd.exe -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -eq 0) + { + Write-Host "components have been successfully added" + } + else + { + Write-Host "components were not installed" + exit 1 + } + - name: Print environment run: | nuget help | grep Version @@ -212,7 +232,7 @@ jobs: f-droid: name: F-Droid Build - runs-on: windows-2019 + runs-on: windows-2022 steps: - name: Setup NuGet uses: nuget/setup-nuget@b2bc17b761a1d88cab755a776c7922eb26eefbfa # v1.0.6 @@ -222,6 +242,26 @@ jobs: - name: Set up MSBuild uses: microsoft/setup-msbuild@ab534842b4bdf384b8aaf93765dc6f721d9f5fab + - name: Work Around for broken Windows 2022 Runner Image + run: | + Set-Location "C:\Program Files (x86)\Microsoft Visual Studio\Installer\" + $InstallPath = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" + $componentsToAdd = @( + "Component.Xamarin" + ) + [string]$workloadArgs = $componentsToAdd | ForEach-Object {" --add " + $_} + $Arguments = ('/c', "vs_installer.exe", 'modify', '--installPath', "`"$InstallPath`"",$workloadArgs, '--quiet', '--norestart', '--nocache') + $process = Start-Process -FilePath cmd.exe -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -eq 0) + { + Write-Host "components have been successfully added" + } + else + { + Write-Host "components were not installed" + exit 1 + } + - name: Print environment run: | nuget help | grep Version diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 782af2d0d..90fb1f826 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -30,7 +30,7 @@ jobs: secrets: "crowdin-api-token" - name: Download translations - uses: crowdin/github-action@e39093fd75daae7859c68eded4b43d42ec78d8ea + uses: crowdin/github-action@12143a68c213f3c6d9913c9e5023224f7231face env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef5c19d2b..b6a054d25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,17 @@ jobs: BRANCH_NAME=$(basename ${{ github.ref }}) echo "::set-output name=branch-name::$BRANCH_NAME" + - name: Create GitHub deployment + uses: chrnorm/deployment-action@1b599fe41a0ef1f95191e7f2eec4743f2d7dfc48 + id: deployment + with: + token: '${{ secrets.GITHUB_TOKEN }}' + initial-status: 'in_progress' + environment: 'production' + description: 'Deployment ${{ steps.version.outputs.version }} from branch ${{ steps.branch.outputs.branch-name }}' + task: release + + - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} uses: dawidd6/action-download-artifact@575b1e4167df67acf7e692af784566618b23c71e # v2.17.10 @@ -87,6 +98,22 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} draft: true + - name: Update deployment status to Success + if: ${{ success() }} + uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86 + with: + token: '${{ secrets.GITHUB_TOKEN }}' + state: 'success' + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + + - name: Update deployment status to Failure + if: ${{ failure() }} + uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86 + with: + token: '${{ secrets.GITHUB_TOKEN }}' + state: 'failure' + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + f-droid: name: F-Droid Release diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index f632f99bd..fefb1f191 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -1,15 +1,67 @@ +--- name: Version Auto Bump on: - # For testing only - workflow_dispatch: - inputs: {} + release: + types: [published] jobs: setup: name: "Setup" runs-on: ubuntu-20.04 + outputs: + version_number: ${{ steps.version.outputs.new-version }} steps: - - name: Stub for testing - run: echo "this is a stub" \ No newline at end of file + - name: Checkout Branch + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 + + - name: Get version to bump + id: version + env: + RELEASE_TAG: ${{ github.event.release.tag }} + run: | + CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]\.)([0-9])/\1/') + CURR_VER=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]\.)([0-9])/\2/') + echo $CURR_VER + + ((CURR_VER++)) + NEW_VER=$CURR_MAJOR$CURR_VER + + echo $NEW_VER + + echo "::set-output name=new-version::$NEW_VER" + + trigger_version_bump: + name: "Trigger version bump workflow" + runs-on: ubuntu-20.04 + needs: + - setup + steps: + - name: Login to Azure + uses: Azure/login@ec3c14589bd3e9312b3cc8c41e6860e258df9010 + with: + creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + + - name: Retrieve secrets + id: retrieve-secrets + env: + KEYVAULT: bitwarden-prod-kv + SECRET: "github-pat-bitwarden-devops-bot-repo-scope" + run: | + VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $SECRET --query value --output tsv) + echo "::add-mask::$VALUE" + echo "::set-output name=$SECRET::$VALUE" + + - name: Call GitHub API to trigger workflow bump + env: + TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + VERSION: ${{ needs.setup.outputs.version_number}} + run: | + JSON_STRING=$(printf '{"ref":"master", "inputs": { "version_number":"%s"}}' "$VERSION") + curl \ + -X POST \ + -i -u bitwarden-devops-bot:$TOKEN \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/bitwarden/mobile/actions/workflows/version-bump.yml/dispatches \ + -d $JSON_STRING diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..6c498a64b --- /dev/null +++ b/renovate.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + "schedule:monthly", + ":maintainLockFilesMonthly", + ":preserveSemverRanges", + ":rebaseStalePrs", + ":disableDependencyDashboard" + ], + "enabledManagers": [ + "nuget" + ], + "packageRules": [ + { + "matchManagers": ["nuget"], + "groupName": "Nuget updates", + "groupSlug": "nuget", + "separateMajorMinor": false + } + ] + } \ No newline at end of file diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index 8826eb443..f85cd5c15 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -151,6 +151,7 @@ + diff --git a/src/Android/Effects/NoEmojiKeyboardEffect.cs b/src/Android/Effects/NoEmojiKeyboardEffect.cs new file mode 100644 index 000000000..618d9d487 --- /dev/null +++ b/src/Android/Effects/NoEmojiKeyboardEffect.cs @@ -0,0 +1,24 @@ +using Android.Widget; +using Bit.Droid.Effects; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; + +[assembly: ExportEffect(typeof(NoEmojiKeyboardEffect), nameof(NoEmojiKeyboardEffect))] +namespace Bit.Droid.Effects +{ + public class NoEmojiKeyboardEffect : PlatformEffect + { + protected override void OnAttached() + { + if (Control is EditText editText) + { + editText.InputType = Android.Text.InputTypes.ClassText | Android.Text.InputTypes.TextVariationVisiblePassword | Android.Text.InputTypes.TextFlagMultiLine; + } + } + + protected override void OnDetached() + { + } + } +} + diff --git a/src/Android/Properties/AndroidManifest.xml b/src/Android/Properties/AndroidManifest.xml index 6cc83c347..12fe37d13 100644 --- a/src/Android/Properties/AndroidManifest.xml +++ b/src/Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + @@ -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/Effects/NoEmojiKeyboardEffect.cs b/src/App/Effects/NoEmojiKeyboardEffect.cs new file mode 100644 index 000000000..285d2b032 --- /dev/null +++ b/src/App/Effects/NoEmojiKeyboardEffect.cs @@ -0,0 +1,12 @@ +using System; +using Xamarin.Forms; + +namespace Bit.App.Effects +{ + public class NoEmojiKeyboardEffect : RoutingEffect + { + public NoEmojiKeyboardEffect() + : base("Bitwarden.NoEmojiKeyboardEffect") + { } + } +} diff --git a/src/App/Pages/Accounts/HintPage.xaml b/src/App/Pages/Accounts/HintPage.xaml index 1514acfbc..6eb09e2f3 100644 --- a/src/App/Pages/Accounts/HintPage.xaml +++ b/src/App/Pages/Accounts/HintPage.xaml @@ -14,7 +14,7 @@ - + diff --git a/src/App/Pages/Accounts/HintPage.xaml.cs b/src/App/Pages/Accounts/HintPage.xaml.cs index 178ad5db1..b1f1d762a 100644 --- a/src/App/Pages/Accounts/HintPage.xaml.cs +++ b/src/App/Pages/Accounts/HintPage.xaml.cs @@ -1,5 +1,4 @@ -using System; -using Xamarin.Forms; +using Xamarin.Forms; namespace Bit.App.Pages { @@ -24,14 +23,6 @@ namespace Bit.App.Pages RequestFocus(_email); } - private async void Submit_Clicked(object sender, EventArgs e) - { - if (DoOnce()) - { - await _vm.SubmitAsync(); - } - } - private async void Close_Clicked(object sender, System.EventArgs e) { if (DoOnce()) diff --git a/src/App/Pages/Accounts/HintPageViewModel.cs b/src/App/Pages/Accounts/HintPageViewModel.cs index b074012e8..2e59f98c9 100644 --- a/src/App/Pages/Accounts/HintPageViewModel.cs +++ b/src/App/Pages/Accounts/HintPageViewModel.cs @@ -1,10 +1,11 @@ using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Abstractions; using Bit.App.Resources; using Bit.Core.Abstractions; using Bit.Core.Exceptions; using Bit.Core.Utilities; -using Xamarin.Forms; +using Xamarin.CommunityToolkit.ObjectModel; namespace Bit.App.Pages { @@ -13,18 +14,26 @@ namespace Bit.App.Pages private readonly IDeviceActionService _deviceActionService; private readonly IPlatformUtilsService _platformUtilsService; private readonly IApiService _apiService; + private readonly ILogger _logger; public HintPageViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _apiService = ServiceContainer.Resolve("apiService"); + _logger = ServiceContainer.Resolve(); PageTitle = AppResources.PasswordHint; - SubmitCommand = new Command(async () => await SubmitAsync()); + SubmitCommand = new AsyncCommand(SubmitAsync, + onException: ex => + { + _logger.Exception(ex); + _deviceActionService.DisplayAlertAsync(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok).FireAndForget(); + }, + allowsMultipleExecutions: false); } - public Command SubmitCommand { get; } + public ICommand SubmitCommand { get; } public string Email { get; set; } public async Task SubmitAsync() @@ -37,14 +46,14 @@ namespace Bit.App.Pages } if (string.IsNullOrWhiteSpace(Email)) { - await Page.DisplayAlert(AppResources.AnErrorHasOccurred, + await _deviceActionService.DisplayAlertAsync(AppResources.AnErrorHasOccurred, string.Format(AppResources.ValidationFieldRequired, AppResources.EmailAddress), AppResources.Ok); return; } if (!Email.Contains("@")) { - await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.InvalidEmail, AppResources.Ok); + await _deviceActionService.DisplayAlertAsync(AppResources.AnErrorHasOccurred, AppResources.InvalidEmail, AppResources.Ok); return; } @@ -54,7 +63,7 @@ namespace Bit.App.Pages await _apiService.PostPasswordHintAsync( new Core.Models.Request.PasswordHintRequest { Email = Email }); await _deviceActionService.HideLoadingAsync(); - await Page.DisplayAlert(null, AppResources.PasswordHintAlert, AppResources.Ok); + await _deviceActionService.DisplayAlertAsync(null, AppResources.PasswordHintAlert, AppResources.Ok); await Page.Navigation.PopModalAsync(); } catch (ApiException e) diff --git a/src/App/Pages/Accounts/LoginPage.xaml b/src/App/Pages/Accounts/LoginPage.xaml index 3975a0ccb..e1627cb04 100644 --- a/src/App/Pages/Accounts/LoginPage.xaml +++ b/src/App/Pages/Accounts/LoginPage.xaml @@ -55,6 +55,7 @@ diff --git a/src/App/Pages/Accounts/LoginPage.xaml.cs b/src/App/Pages/Accounts/LoginPage.xaml.cs index 22e3f49e2..b6e74e1c9 100644 --- a/src/App/Pages/Accounts/LoginPage.xaml.cs +++ b/src/App/Pages/Accounts/LoginPage.xaml.cs @@ -1,8 +1,8 @@ using System; using System.Threading.Tasks; using Bit.App.Models; -using Bit.App.Resources; using Bit.App.Utilities; +using Bit.Core.Abstractions; using Bit.Core.Utilities; using Xamarin.Forms; @@ -15,6 +15,8 @@ namespace Bit.App.Pages private bool _inputFocused; + readonly LazyResolve _logger = new LazyResolve("logger"); + public LoginPage(string email = null, AppOptions appOptions = null) { _appOptions = appOptions; @@ -30,11 +32,10 @@ namespace Bit.App.Pages await _accountListOverlay.HideAsync(); await Navigation.PopModalAsync(); }; - if (!string.IsNullOrWhiteSpace(email)) - { - _email.IsEnabled = false; - } - else + _vm.IsEmailEnabled = string.IsNullOrWhiteSpace(email); + _vm.IsIosExtension = _appOptions?.IosExtension ?? false; + + if (_vm.IsEmailEnabled) { _vm.ShowCancelButton = true; } @@ -53,7 +54,7 @@ namespace Bit.App.Pages ToolbarItems.Add(_getPasswordHint); } - if (Device.RuntimePlatform == Device.Android && !_email.IsEnabled) + if (Device.RuntimePlatform == Device.Android && !_vm.IsEmailEnabled) { ToolbarItems.Add(_removeAccount); } @@ -110,7 +111,7 @@ namespace Bit.App.Pages { if (DoOnce()) { - await _vm.LogInAsync(true, _email.IsEnabled); + await _vm.LogInAsync(true, _vm.IsEmailEnabled); } } @@ -139,26 +140,16 @@ namespace Bit.App.Pages } } - private async void More_Clicked(object sender, System.EventArgs e) + private async void More_Clicked(object sender, EventArgs e) { - await _accountListOverlay.HideAsync(); - if (!DoOnce()) + try { - return; + await _accountListOverlay.HideAsync(); + _vm.MoreCommand.Execute(null); } - - var buttons = _email.IsEnabled ? new[] { AppResources.GetPasswordHint } - : new[] { AppResources.GetPasswordHint, AppResources.RemoveAccount }; - var selection = await DisplayActionSheet(AppResources.Options, - AppResources.Cancel, null, buttons); - - if (selection == AppResources.GetPasswordHint) + catch (Exception ex) { - await Navigation.PushModalAsync(new NavigationPage(new HintPage())); - } - else if (selection == AppResources.RemoveAccount) - { - await _vm.RemoveAccountAsync(); + _logger.Value.Exception(ex); } } diff --git a/src/App/Pages/Accounts/LoginPageViewModel.cs b/src/App/Pages/Accounts/LoginPageViewModel.cs index 60cc5a247..593b55e91 100644 --- a/src/App/Pages/Accounts/LoginPageViewModel.cs +++ b/src/App/Pages/Accounts/LoginPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Abstractions; using Bit.App.Controls; using Bit.App.Resources; @@ -8,6 +9,7 @@ using Bit.Core; using Bit.Core.Abstractions; using Bit.Core.Exceptions; using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; using Xamarin.Forms; namespace Bit.App.Pages @@ -28,6 +30,7 @@ namespace Bit.App.Pages private bool _showCancelButton; private string _email; private string _masterPassword; + private bool _isEmailEnabled; public LoginPageViewModel() { @@ -44,6 +47,7 @@ namespace Bit.App.Pages PageTitle = AppResources.Bitwarden; TogglePasswordCommand = new Command(TogglePassword); LogInCommand = new Command(async () => await LogInAsync()); + MoreCommand = new AsyncCommand(MoreAsync, onException: _logger.Exception, allowsMultipleExecutions: false); AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) { @@ -81,10 +85,19 @@ namespace Bit.App.Pages set => SetProperty(ref _masterPassword, value); } + public bool IsEmailEnabled + { + get => _isEmailEnabled; + set => SetProperty(ref _isEmailEnabled, value); + } + + public bool IsIosExtension { get; set; } + public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } public Command LogInCommand { get; } public Command TogglePasswordCommand { get; } + public ICommand MoreCommand { get; internal set; } public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye; public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; public Action StartTwoFactorAction { get; set; } @@ -201,6 +214,28 @@ namespace Bit.App.Pages } } + private async Task MoreAsync() + { + var buttons = IsEmailEnabled + ? new[] { AppResources.GetPasswordHint } + : new[] { AppResources.GetPasswordHint, AppResources.RemoveAccount }; + var selection = await _deviceActionService.DisplayActionSheetAsync(AppResources.Options, AppResources.Cancel, null, buttons); + + if (selection == AppResources.GetPasswordHint) + { + var hintNavigationPage = new NavigationPage(new HintPage()); + if (IsIosExtension) + { + ThemeManager.ApplyResourcesTo(hintNavigationPage); + } + await Page.Navigation.PushModalAsync(hintNavigationPage); + } + else if (selection == AppResources.RemoveAccount) + { + await RemoveAccountAsync(); + } + } + public void TogglePassword() { ShowPassword = !ShowPassword; diff --git a/src/App/Pages/Accounts/LoginSsoPage.xaml.cs b/src/App/Pages/Accounts/LoginSsoPage.xaml.cs index 5b7878e55..3871dc8e5 100644 --- a/src/App/Pages/Accounts/LoginSsoPage.xaml.cs +++ b/src/App/Pages/Accounts/LoginSsoPage.xaml.cs @@ -69,12 +69,12 @@ namespace Bit.App.Pages } } - private async void LogIn_Clicked(object sender, EventArgs e) + private void LogIn_Clicked(object sender, EventArgs e) { if (DoOnce()) { CopyAppOptions(); - await _vm.LogInAsync(); + _vm.LogInCommand.Execute(null); } } diff --git a/src/App/Pages/Accounts/LoginSsoPageViewModel.cs b/src/App/Pages/Accounts/LoginSsoPageViewModel.cs index 61dad0114..616e3ad40 100644 --- a/src/App/Pages/Accounts/LoginSsoPageViewModel.cs +++ b/src/App/Pages/Accounts/LoginSsoPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Abstractions; using Bit.App.Resources; using Bit.App.Utilities; @@ -8,13 +9,15 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Domain; using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; using Xamarin.Essentials; -using Xamarin.Forms; namespace Bit.App.Pages { public class LoginSsoPageViewModel : BaseViewModel { + private const string REDIRECT_URI = "bitwarden://sso-callback"; + private readonly IDeviceActionService _deviceActionService; private readonly IAuthService _authService; private readonly ISyncService _syncService; @@ -23,6 +26,7 @@ namespace Bit.App.Pages private readonly ICryptoFunctionService _cryptoFunctionService; private readonly IPlatformUtilsService _platformUtilsService; private readonly IStateService _stateService; + private readonly ILogger _logger; private string _orgIdentifier; @@ -37,9 +41,11 @@ namespace Bit.App.Pages _cryptoFunctionService = ServiceContainer.Resolve("cryptoFunctionService"); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _stateService = ServiceContainer.Resolve("stateService"); + _logger = ServiceContainer.Resolve("logger"); + PageTitle = AppResources.Bitwarden; - LogInCommand = new Command(async () => await LogInAsync()); + LogInCommand = new AsyncCommand(LogInAsync, allowsMultipleExecutions: false); } public string OrgIdentifier @@ -48,7 +54,7 @@ namespace Bit.App.Pages set => SetProperty(ref _orgIdentifier, value); } - public Command LogInCommand { get; } + public ICommand LogInCommand { get; } public Action StartTwoFactorAction { get; set; } public Action StartSetPasswordAction { get; set; } public Action SsoAuthSuccessAction { get; set; } @@ -65,81 +71,91 @@ namespace Bit.App.Pages public async Task LogInAsync() { - if (Connectivity.NetworkAccess == NetworkAccess.None) - { - await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, - AppResources.InternetConnectionRequiredTitle); - return; - } - if (string.IsNullOrWhiteSpace(OrgIdentifier)) - { - await _platformUtilsService.ShowDialogAsync( - string.Format(AppResources.ValidationFieldRequired, AppResources.OrgIdentifier), - AppResources.AnErrorHasOccurred, - AppResources.Ok); - return; - } - - await _deviceActionService.ShowLoadingAsync(AppResources.LoggingIn); - string ssoToken; - try { + if (Connectivity.NetworkAccess == NetworkAccess.None) + { + await _platformUtilsService.ShowDialogAsync(AppResources.InternetConnectionRequiredMessage, + AppResources.InternetConnectionRequiredTitle); + return; + } + if (string.IsNullOrWhiteSpace(OrgIdentifier)) + { + await _platformUtilsService.ShowDialogAsync( + string.Format(AppResources.ValidationFieldRequired, AppResources.OrgIdentifier), + AppResources.AnErrorHasOccurred, + AppResources.Ok); + return; + } + + await _deviceActionService.ShowLoadingAsync(AppResources.LoggingIn); + var response = await _apiService.PreValidateSso(OrgIdentifier); - ssoToken = response.Token; + + if (string.IsNullOrWhiteSpace(response?.Token)) + { + _logger.Error(response is null ? "Login SSO Error: response is null" : "Login SSO Error: response.Token is null or whitespace"); + await _deviceActionService.HideLoadingAsync(); + await _platformUtilsService.ShowDialogAsync(AppResources.LoginSsoError); + return; + } + + var ssoToken = response.Token; + + + var passwordOptions = new PasswordGenerationOptions(true); + passwordOptions.Length = 64; + + var codeVerifier = await _passwordGenerationService.GeneratePasswordAsync(passwordOptions); + var codeVerifierHash = await _cryptoFunctionService.HashAsync(codeVerifier, CryptoHashAlgorithm.Sha256); + var codeChallenge = CoreHelpers.Base64UrlEncode(codeVerifierHash); + + var state = await _passwordGenerationService.GeneratePasswordAsync(passwordOptions); + + var url = _apiService.IdentityBaseUrl + "/connect/authorize?" + + "client_id=" + _platformUtilsService.GetClientType().GetString() + "&" + + "redirect_uri=" + Uri.EscapeDataString(REDIRECT_URI) + "&" + + "response_type=code&scope=api%20offline_access&" + + "state=" + state + "&code_challenge=" + codeChallenge + "&" + + "code_challenge_method=S256&response_mode=query&" + + "domain_hint=" + Uri.EscapeDataString(OrgIdentifier) + "&" + + "ssoToken=" + Uri.EscapeDataString(ssoToken); + + WebAuthenticatorResult authResult = null; + + authResult = await WebAuthenticator.AuthenticateAsync(new Uri(url), + new Uri(REDIRECT_URI)); + + + var code = GetResultCode(authResult, state); + if (!string.IsNullOrEmpty(code)) + { + await LogIn(code, codeVerifier, OrgIdentifier); + } + else + { + await _deviceActionService.HideLoadingAsync(); + await _platformUtilsService.ShowDialogAsync(AppResources.LoginSsoError, + AppResources.AnErrorHasOccurred); + } } catch (ApiException e) { + _logger.Exception(e); await _deviceActionService.HideLoadingAsync(); - await _platformUtilsService.ShowDialogAsync( - (e?.Error != null ? e.Error.GetSingleMessage() : AppResources.LoginSsoError), + await _platformUtilsService.ShowDialogAsync(e?.Error?.GetSingleMessage() ?? AppResources.LoginSsoError, AppResources.AnErrorHasOccurred); - return; - } - - var passwordOptions = new PasswordGenerationOptions(true); - passwordOptions.Length = 64; - - var codeVerifier = await _passwordGenerationService.GeneratePasswordAsync(passwordOptions); - var codeVerifierHash = await _cryptoFunctionService.HashAsync(codeVerifier, CryptoHashAlgorithm.Sha256); - var codeChallenge = CoreHelpers.Base64UrlEncode(codeVerifierHash); - - var state = await _passwordGenerationService.GeneratePasswordAsync(passwordOptions); - - var redirectUri = "bitwarden://sso-callback"; - - var url = _apiService.IdentityBaseUrl + "/connect/authorize?" + - "client_id=" + _platformUtilsService.GetClientType().GetString() + "&" + - "redirect_uri=" + Uri.EscapeDataString(redirectUri) + "&" + - "response_type=code&scope=api%20offline_access&" + - "state=" + state + "&code_challenge=" + codeChallenge + "&" + - "code_challenge_method=S256&response_mode=query&" + - "domain_hint=" + Uri.EscapeDataString(OrgIdentifier) + "&" + - "ssoToken=" + Uri.EscapeDataString(ssoToken); - - WebAuthenticatorResult authResult = null; - try - { - authResult = await WebAuthenticator.AuthenticateAsync(new Uri(url), - new Uri(redirectUri)); } catch (TaskCanceledException) { // user canceled await _deviceActionService.HideLoadingAsync(); - return; } - - var code = GetResultCode(authResult, state); - if (!string.IsNullOrEmpty(code)) - { - await LogIn(code, codeVerifier, redirectUri, OrgIdentifier); - } - else + catch (Exception ex) { + _logger.Exception(ex); await _deviceActionService.HideLoadingAsync(); - await _platformUtilsService.ShowDialogAsync(AppResources.LoginSsoError, - AppResources.AnErrorHasOccurred); + await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred); } } @@ -158,11 +174,11 @@ namespace Bit.App.Pages return code; } - private async Task LogIn(string code, string codeVerifier, string redirectUri, string orgId) + private async Task LogIn(string code, string codeVerifier, string orgId) { try { - var response = await _authService.LogInSsoAsync(code, codeVerifier, redirectUri, orgId); + var response = await _authService.LogInSsoAsync(code, codeVerifier, REDIRECT_URI, orgId); await AppHelpers.ResetInvalidUnlockAttemptsAsync(); await _stateService.SetRememberedOrgIdentifierAsync(OrgIdentifier); await _deviceActionService.HideLoadingAsync(); diff --git a/src/App/Pages/Accounts/RegisterPage.xaml b/src/App/Pages/Accounts/RegisterPage.xaml index 1fa519145..b6db138f4 100644 --- a/src/App/Pages/Accounts/RegisterPage.xaml +++ b/src/App/Pages/Accounts/RegisterPage.xaml @@ -132,7 +132,7 @@ IsToggled="{Binding AcceptPolicies}" StyleClass="box-value" HorizontalOptions="Start" - Margin="{Binding SwitchMargin}"/> + Margin="0, 0, 10, 0"/> @@ -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 6b46ab330..b68e6a9ce 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}" @@ -89,6 +90,13 @@ BackgroundColor="{DynamicResource BackgroundColor}"/> + + + @@ -142,6 +150,7 @@ HeaderTemplate="{StaticResource headerTemplate}" CipherTemplate="{StaticResource cipherTemplate}" GroupTemplate="{StaticResource groupTemplate}" + AuthenticatorTemplate="{StaticResource authenticatorTemplate}" LoginCipherTemplate="{StaticResource loginCipherTemplate}" /> @@ -168,7 +177,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; @@ -61,6 +63,7 @@ namespace Bit.App.Pages _vm.FolderId = folderId; _vm.CollectionId = collectionId; _vm.Deleted = deleted; + _vm.ShowTotp = showTotp; _previousPage = previousPage; if (pageTitle != null) { @@ -82,7 +85,7 @@ namespace Bit.App.Pages ToolbarItems.Add(_lockItem); ToolbarItems.Add(_exitItem); } - if (deleted) + if (deleted || showTotp) { _absLayout.Children.Remove(_fab); ToolbarItems.Remove(_addItem); @@ -202,10 +205,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(); @@ -213,35 +217,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 84f4868a4..cf4706250 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs @@ -8,6 +8,7 @@ namespace Bit.App.Pages public DataTemplate CipherTemplate { get; set; } public DataTemplate GroupTemplate { get; set; } public DataTemplate LoginCipherTemplate { get; set; } + public DataTemplate AuthenticatorTemplate { get; set; } protected override DataTemplate OnSelectTemplate(object item, BindableObject container) { @@ -16,6 +17,11 @@ namespace Bit.App.Pages return HeaderTemplate; } + if (item is GroupingsPageTOTPListItem) + { + return AuthenticatorTemplate; + } + if (item is GroupingsPageListItem listItem) { if (listItem.Cipher is null) @@ -27,6 +33,7 @@ namespace Bit.App.Pages ? LoginCipherTemplate : CipherTemplate; } + 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..7a49ef857 --- /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?.Replace(" ", string.Empty)); + _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 eea5aa10e..1987b0518 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; @@ -76,6 +81,9 @@ namespace Bit.App.Pages CipherOptionsCommand = new Command(CipherOptionsAsync); CopyUsernameItemCommand = new AsyncCommand(CopyUsernameItemAsync, onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false); CopyPasswordItemCommand = new AsyncCommand(CopyPasswordItemAsync, onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false); + VaultFilterCommand = new AsyncCommand(VaultFilterOptionsAsync, + onException: ex => _logger.Exception(ex), + allowsMultipleExecutions: false); AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) { @@ -96,6 +104,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; } @@ -153,9 +162,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; } @@ -193,13 +205,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; @@ -223,6 +236,8 @@ namespace Bit.App.Pages } if (MainPage) { + AddTotpGroupItem(canAccessPremium, groupedItems, uppercaseGroupNames); + groupedItems.Add(new GroupingsPageListGroup( AppResources.Types, 4, uppercaseGroupNames, !hasFavorites) { @@ -279,10 +294,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) { @@ -370,6 +387,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; @@ -430,6 +501,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, @@ -467,6 +545,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(); @@ -492,6 +571,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"> - - - + + - - +