diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e28ba9a8..6a90503b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,11 @@ on: - Initial Release - Redeploy - Dry Run + fdroid_publish: + description: 'Publish to f-droid store' + required: true + default: true + type: boolean jobs: release: @@ -78,6 +83,7 @@ jobs: name: F-Droid Release runs-on: ubuntu-20.04 needs: release + if: inputs.fdroid_publish steps: - name: Checkout repo uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0 diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 0b5c86e46..b3c5a58ef 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -19,12 +19,6 @@ jobs: - name: Create Version Branch run: | git switch -c version_bump_${{ github.event.inputs.version_number }} - git push -u origin version_bump_${{ github.event.inputs.version_number }} - - - name: Checkout Version Branch - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 - with: - ref: version_bump_${{ github.event.inputs.version_number }} - name: Bump Version - Android XML uses: bitwarden/gh-actions/version-bump@03ad9a873c39cdc95dd8d77dbbda67f84db43945 @@ -56,16 +50,32 @@ jobs: version: ${{ github.event.inputs.version_number }} file_path: "./src/iOS/Info.plist" - - name: Commit files + - name: Setup git run: | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" + + - name: Check if version changed + id: version-changed + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::set-output name=changes_to_commit::TRUE" + else + echo "::set-output name=changes_to_commit::FALSE" + echo "No changes to commit!"; + fi + + - name: Commit files + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + run: | git commit -m "Bumped version to ${{ github.event.inputs.version_number }}" -a - name: Push changes + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: git push -u origin version_bump_${{ github.event.inputs.version_number }} - name: Create Version PR + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} env: PR_BRANCH: "version_bump_${{ github.event.inputs.version_number }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/src/Android/Accessibility/AccessibilityHelpers.cs b/src/Android/Accessibility/AccessibilityHelpers.cs index 82bb3b4a5..81d7fa65f 100644 --- a/src/Android/Accessibility/AccessibilityHelpers.cs +++ b/src/Android/Accessibility/AccessibilityHelpers.cs @@ -54,6 +54,7 @@ namespace Bit.Droid.Accessibility new Browser("com.google.android.apps.chrome", "url_bar"), new Browser("com.google.android.apps.chrome_dev", "url_bar"), // Rem. for "com.google.android.captiveportallogin": URL displayed in ActionBar subtitle without viewId. + new Browser("com.iode.firefox", "mozac_browser_toolbar_url_view"), new Browser("com.jamal2367.styx", "search"), new Browser("com.kiwibrowser.browser", "url_bar"), new Browser("com.kiwibrowser.browser.dev", "url_bar"), @@ -67,6 +68,7 @@ namespace Bit.Droid.Accessibility new Browser("com.naver.whale", "url_bar"), new Browser("com.opera.browser", "url_field"), new Browser("com.opera.browser.beta", "url_field"), + new Browser("com.opera.gx", "addressbarEdit"), new Browser("com.opera.mini.native", "url_field"), new Browser("com.opera.mini.native.beta", "url_field"), new Browser("com.opera.touch", "addressbarEdit"), diff --git a/src/Android/Autofill/AutofillHelpers.cs b/src/Android/Autofill/AutofillHelpers.cs index 4549e56b8..8ec3416bd 100644 --- a/src/Android/Autofill/AutofillHelpers.cs +++ b/src/Android/Autofill/AutofillHelpers.cs @@ -73,6 +73,7 @@ namespace Bit.Droid.Autofill "com.google.android.apps.chrome", "com.google.android.apps.chrome_dev", "com.google.android.captiveportallogin", + "com.iode.firefox", "com.jamal2367.styx", "com.kiwibrowser.browser", "com.kiwibrowser.browser.dev", @@ -86,6 +87,7 @@ namespace Bit.Droid.Autofill "com.naver.whale", "com.opera.browser", "com.opera.browser.beta", + "com.opera.gx", "com.opera.mini.native", "com.opera.mini.native.beta", "com.opera.touch", diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index af98b744c..39fdfa6f1 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -64,10 +64,11 @@ namespace Bit.Droid Intent?.Validate(); base.OnCreate(savedInstanceState); - if (!CoreHelpers.InDebugMode()) + + _deviceActionService.SetScreenCaptureAllowedAsync().FireAndForget(_ => { Window.AddFlags(Android.Views.WindowManagerFlags.Secure); - } + }); ServiceContainer.Resolve("logger").InitAsync(); diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index 991aea474..bea0569ff 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -20,6 +20,7 @@ using System.Net; using Bit.App.Utilities; using Bit.App.Pages; using Bit.App.Utilities.AccountManagement; +using Bit.App.Controls; #if !FDROID using Android.Gms.Security; #endif @@ -69,7 +70,8 @@ namespace Bit.Droid ServiceContainer.Resolve("secureStorageService"), ServiceContainer.Resolve("stateService"), ServiceContainer.Resolve("platformUtilsService"), - ServiceContainer.Resolve("authService")); + ServiceContainer.Resolve("authService"), + ServiceContainer.Resolve("logger")); ServiceContainer.Register("accountsManager", accountsManager); } #if !FDROID @@ -160,6 +162,7 @@ namespace Bit.Droid ServiceContainer.Register("cryptoFunctionService", cryptoFunctionService); ServiceContainer.Register("cryptoService", cryptoService); ServiceContainer.Register("passwordRepromptService", passwordRepromptService); + ServiceContainer.Register("avatarImageSourcePool", new AvatarImageSourcePool()); // Push #if FDROID diff --git a/src/Android/Resources/xml/autofillservice.xml b/src/Android/Resources/xml/autofillservice.xml index c65812815..f47e35f8c 100644 --- a/src/Android/Resources/xml/autofillservice.xml +++ b/src/Android/Resources/xml/autofillservice.xml @@ -77,6 +77,9 @@ + @@ -116,6 +119,9 @@ + diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index df873f79c..c981844ca 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -948,5 +948,21 @@ namespace Bit.Droid.Services { // for any Android-specific cleanup required after switching accounts } + + public async Task SetScreenCaptureAllowedAsync() + { + if (CoreHelpers.ForceScreenCaptureEnabled()) + { + return; + } + + var activity = CrossCurrentActivity.Current?.Activity; + if (await _stateService.GetScreenCaptureAllowedAsync()) + { + activity.RunOnUiThread(() => activity.Window.ClearFlags(WindowManagerFlags.Secure)); + return; + } + activity.RunOnUiThread(() => activity.Window.AddFlags(WindowManagerFlags.Secure)); + } } } diff --git a/src/App/Abstractions/IAccountsManager.cs b/src/App/Abstractions/IAccountsManager.cs index 684230d01..37acbc1db 100644 --- a/src/App/Abstractions/IAccountsManager.cs +++ b/src/App/Abstractions/IAccountsManager.cs @@ -8,5 +8,6 @@ namespace Bit.App.Abstractions { void Init(Func getOptionsFunc, IAccountsManagerHost accountsManagerHost); Task NavigateOnAccountChangeAsync(bool? isAuthed = null); + Task LogOutAsync(string userId, bool userInitiated, bool expired); } } diff --git a/src/App/Abstractions/IDeviceActionService.cs b/src/App/Abstractions/IDeviceActionService.cs index 28eb7b712..1c7f75941 100644 --- a/src/App/Abstractions/IDeviceActionService.cs +++ b/src/App/Abstractions/IDeviceActionService.cs @@ -48,5 +48,6 @@ namespace Bit.App.Abstractions bool SupportsFido2(); float GetSystemFontSizeScale(); Task OnAccountSwitchCompleteAsync(); + Task SetScreenCaptureAllowedAsync(); } } diff --git a/src/App/App.csproj b/src/App/App.csproj index a6ba8249d..25ac5fc6b 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -129,12 +129,10 @@ + - - MSBuild:UpdateDesignTimeXaml - @@ -162,12 +160,6 @@ - - - MSBuild:UpdateDesignTimeXaml - - - AppResources.cs.resx @@ -422,5 +414,6 @@ + diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 3415fd7e9..8d2b74538 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -301,7 +301,7 @@ namespace Bit.App UpdateThemeAsync(); }; Current.MainPage = new NavigationPage(new HomePage(Options)); - var mainPageTask = _accountsManager.NavigateOnAccountChangeAsync(); + _accountsManager.NavigateOnAccountChangeAsync().FireAndForget(); ServiceContainer.Resolve("platformUtilsService").Init(); } diff --git a/src/App/Controls/AccountViewCell/AccountViewCellViewModel.cs b/src/App/Controls/AccountViewCell/AccountViewCellViewModel.cs index 3c2da33f2..e99b80f44 100644 --- a/src/App/Controls/AccountViewCell/AccountViewCellViewModel.cs +++ b/src/App/Controls/AccountViewCell/AccountViewCellViewModel.cs @@ -13,7 +13,8 @@ namespace Bit.App.Controls public AccountViewCellViewModel(AccountView accountView) { AccountView = accountView; - AvatarImageSource = new AvatarImageSource(AccountView.Name, AccountView.Email); + AvatarImageSource = ServiceContainer.Resolve("avatarImageSourcePool") + ?.GetOrCreateAvatar(AccountView.Name, AccountView.Email); } public AccountView AccountView diff --git a/src/App/Controls/AvatarImageSource.cs b/src/App/Controls/AvatarImageSource.cs index fd9a86308..38f3df31c 100644 --- a/src/App/Controls/AvatarImageSource.cs +++ b/src/App/Controls/AvatarImageSource.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using SkiaSharp; @@ -50,7 +51,7 @@ namespace Bit.App.Controls private Stream Draw() { - string chars = null; + string chars; string upperData = null; if (string.IsNullOrEmpty(_data)) @@ -71,62 +72,83 @@ namespace Bit.App.Controls var textColor = Color.White; var size = 50; - var bitmap = new SKBitmap( - size * 2, + using (var bitmap = new SKBitmap(size * 2, size * 2, SKImageInfo.PlatformColorType, - SKAlphaType.Premul); - var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Transparent); - - var midX = canvas.LocalClipBounds.Size.ToSizeI().Width / 2; - var midY = canvas.LocalClipBounds.Size.ToSizeI().Height / 2; - var radius = midX - midX / 5; - - var circlePaint = new SKPaint + SKAlphaType.Premul)) { - IsAntialias = true, - Style = SKPaintStyle.Fill, - StrokeJoin = SKStrokeJoin.Miter, - Color = SKColor.Parse(bgColor.ToHex()) - }; - canvas.DrawCircle(midX, midY, radius, circlePaint); + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Clear(SKColors.Transparent); + using (var paint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + StrokeJoin = SKStrokeJoin.Miter, + Color = SKColor.Parse(bgColor.ToHex()) + }) + { + var midX = canvas.LocalClipBounds.Size.ToSizeI().Width / 2; + var midY = canvas.LocalClipBounds.Size.ToSizeI().Height / 2; + var radius = midX - midX / 5; - var typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Normal); - var textSize = midX / 1.3f; - var textPaint = new SKPaint - { - IsAntialias = true, - Style = SKPaintStyle.Fill, - Color = SKColor.Parse(textColor.ToHex()), - TextSize = textSize, - TextAlign = SKTextAlign.Center, - Typeface = typeface - }; - var rect = new SKRect(); - textPaint.MeasureText(chars, ref rect); - canvas.DrawText(chars, midX, midY + rect.Height / 2, textPaint); + using (var circlePaint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + StrokeJoin = SKStrokeJoin.Miter, + Color = SKColor.Parse(bgColor.ToHex()) + }) + { + canvas.DrawCircle(midX, midY, radius, circlePaint); - return SKImage.FromBitmap(bitmap).Encode(SKEncodedImageFormat.Png, 100).AsStream(); + var typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Normal); + var textSize = midX / 1.3f; + using (var textPaint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + Color = SKColor.Parse(textColor.ToHex()), + TextSize = textSize, + TextAlign = SKTextAlign.Center, + Typeface = typeface + }) + { + var rect = new SKRect(); + textPaint.MeasureText(chars, ref rect); + canvas.DrawText(chars, midX, midY + rect.Height / 2, textPaint); + + using (var img = SKImage.FromBitmap(bitmap)) + { + var data = img.Encode(SKEncodedImageFormat.Png, 100); + return data?.AsStream(true); + } + } + } + } + } + } } private string GetFirstLetters(string data, int charCount) { - var parts = data.Split(); + var sanitizedData = data.Trim(); + var parts = sanitizedData.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 1 && charCount <= 2) { - var text = ""; - for (int i = 0; i < charCount; i++) + var text = string.Empty; + for (var i = 0; i < charCount; i++) { - text += parts[i].Substring(0, 1); + text += parts[i][0]; } return text; } - if (data.Length > 2) + if (sanitizedData.Length > 2) { - return data.Substring(0, 2); + return sanitizedData.Substring(0, 2); } - return data; + return sanitizedData; } private Color StringToColor(string str) diff --git a/src/App/Controls/AvatarImageSourcePool.cs b/src/App/Controls/AvatarImageSourcePool.cs new file mode 100644 index 000000000..53e463e80 --- /dev/null +++ b/src/App/Controls/AvatarImageSourcePool.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Concurrent; + +namespace Bit.App.Controls +{ + public interface IAvatarImageSourcePool + { + AvatarImageSource GetOrCreateAvatar(string name, string email); + } + + public class AvatarImageSourcePool : IAvatarImageSourcePool + { + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + + public AvatarImageSource GetOrCreateAvatar(string name, string email) + { + var key = $"{name}{email}"; + if (!_cache.TryGetValue(key, out var avatar)) + { + avatar = new AvatarImageSource(name, email); + if (!_cache.TryAdd(key, avatar) + && + !_cache.TryGetValue(key, out avatar)) // If add fails another thread created the avatar in between the first try get and the try add. + { + // if add and get after fails, then something wrong is going on with this method. + throw new InvalidOperationException("Something is wrong creating the avatar image"); + } + } + return avatar; + } + } +} + diff --git a/src/App/Controls/DateTime/DateTimePicker.xaml b/src/App/Controls/DateTime/DateTimePicker.xaml new file mode 100644 index 000000000..fe270c6ea --- /dev/null +++ b/src/App/Controls/DateTime/DateTimePicker.xaml @@ -0,0 +1,20 @@ + + + + + diff --git a/src/App/Controls/DateTime/DateTimePicker.xaml.cs b/src/App/Controls/DateTime/DateTimePicker.xaml.cs new file mode 100644 index 000000000..8bcd5346c --- /dev/null +++ b/src/App/Controls/DateTime/DateTimePicker.xaml.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using Xamarin.CommunityToolkit.UI.Views; +using Xamarin.Forms; + +namespace Bit.App.Controls +{ + public partial class DateTimePicker : Grid + { + public DateTimePicker() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + base.OnPropertyChanged(propertyName); + + if (propertyName == nameof(BindingContext) + && + BindingContext is DateTimeViewModel dateTimeViewModel) + { + AutomationProperties.SetName(_datePicker, dateTimeViewModel.DateName); + AutomationProperties.SetName(_timePicker, dateTimeViewModel.TimeName); + + _datePicker.PlaceHolder = dateTimeViewModel.DatePlaceholder; + _timePicker.PlaceHolder = dateTimeViewModel.TimePlaceholder; + } + } + } + + public class LazyDateTimePicker : LazyView + { + } +} diff --git a/src/App/Controls/DateTime/DateTimeViewModel.cs b/src/App/Controls/DateTime/DateTimeViewModel.cs new file mode 100644 index 000000000..ac940a773 --- /dev/null +++ b/src/App/Controls/DateTime/DateTimeViewModel.cs @@ -0,0 +1,70 @@ +using System; +using Bit.Core.Utilities; + +namespace Bit.App.Controls +{ + public class DateTimeViewModel : ExtendedViewModel + { + DateTime? _date; + TimeSpan? _time; + + public DateTimeViewModel(string dateName, string timeName) + { + DateName = dateName; + TimeName = timeName; + } + + public Action OnDateChanged { get; set; } + public Action OnTimeChanged { get; set; } + + public DateTime? Date + { + get => _date; + set + { + if (SetProperty(ref _date, value)) + { + OnDateChanged?.Invoke(value); + } + } + } + public TimeSpan? Time + { + get => _time; + set + { + if (SetProperty(ref _time, value)) + { + OnTimeChanged?.Invoke(value); + } + } + } + + public string DateName { get; } + public string TimeName { get; } + + public string DatePlaceholder { get; set; } + public string TimePlaceholder { get; set; } + + public DateTime? DateTime + { + get + { + if (Date.HasValue) + { + if (Time.HasValue) + { + return Date.Value.Add(Time.Value); + } + return Date; + } + return null; + } + set + { + Date = value?.Date; + Time = value?.Date.TimeOfDay; + } + } + } +} diff --git a/src/App/Pages/Accounts/EnvironmentPage.xaml b/src/App/Pages/Accounts/EnvironmentPage.xaml index 5a7abb783..cabf07ed7 100644 --- a/src/App/Pages/Accounts/EnvironmentPage.xaml +++ b/src/App/Pages/Accounts/EnvironmentPage.xaml @@ -14,7 +14,7 @@ - + diff --git a/src/App/Pages/Accounts/EnvironmentPage.xaml.cs b/src/App/Pages/Accounts/EnvironmentPage.xaml.cs index b57d2f440..baa2dd1c0 100644 --- a/src/App/Pages/Accounts/EnvironmentPage.xaml.cs +++ b/src/App/Pages/Accounts/EnvironmentPage.xaml.cs @@ -36,14 +36,6 @@ namespace Bit.App.Pages }; } - private async void Submit_Clicked(object sender, EventArgs e) - { - if (DoOnce()) - { - await _vm.SubmitAsync(); - } - } - private async Task SubmitSuccessAsync() { _platformUtilsService.ShowToast("success", null, AppResources.EnvironmentSaved); diff --git a/src/App/Pages/Accounts/EnvironmentPageViewModel.cs b/src/App/Pages/Accounts/EnvironmentPageViewModel.cs index b25113c41..07ce99a45 100644 --- a/src/App/Pages/Accounts/EnvironmentPageViewModel.cs +++ b/src/App/Pages/Accounts/EnvironmentPageViewModel.cs @@ -1,15 +1,17 @@ using System; using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Resources; using Bit.Core.Abstractions; using Bit.Core.Utilities; -using Xamarin.Forms; +using Xamarin.CommunityToolkit.ObjectModel; namespace Bit.App.Pages { public class EnvironmentPageViewModel : BaseViewModel { private readonly IEnvironmentService _environmentService; + readonly LazyResolve _logger = new LazyResolve("logger"); public EnvironmentPageViewModel() { @@ -22,10 +24,10 @@ namespace Bit.App.Pages IdentityUrl = _environmentService.IdentityUrl; IconsUrl = _environmentService.IconsUrl; NotificationsUrls = _environmentService.NotificationsUrl; - SubmitCommand = new Command(async () => await SubmitAsync()); + SubmitCommand = new AsyncCommand(SubmitAsync, onException: ex => OnSubmitException(ex), allowsMultipleExecutions: false); } - public Command SubmitCommand { get; } + public ICommand SubmitCommand { get; } public string BaseUrl { get; set; } public string ApiUrl { get; set; } public string IdentityUrl { get; set; } @@ -37,6 +39,12 @@ namespace Bit.App.Pages public async Task SubmitAsync() { + if (!ValidateUrls()) + { + await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.EnvironmentPageUrlsError, AppResources.Ok); + return; + } + var resUrls = await _environmentService.SetUrlsAsync(new Core.Models.Data.EnvironmentUrlData { Base = BaseUrl, @@ -57,5 +65,25 @@ namespace Bit.App.Pages SubmitSuccessAction?.Invoke(); } + + public bool ValidateUrls() + { + bool IsUrlValid(string url) + { + return string.IsNullOrEmpty(url) || Uri.IsWellFormedUriString(url, UriKind.Absolute); + } + + return IsUrlValid(BaseUrl) + && IsUrlValid(ApiUrl) + && IsUrlValid(IdentityUrl) + && IsUrlValid(WebVaultUrl) + && IsUrlValid(IconsUrl); + } + + private void OnSubmitException(Exception ex) + { + _logger.Value.Exception(ex); + Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok); + } } } diff --git a/src/App/Pages/Send/SendAddEditPage.xaml b/src/App/Pages/Send/SendAddEditPage.xaml index 40e059125..018e78403 100644 --- a/src/App/Pages/Send/SendAddEditPage.xaml +++ b/src/App/Pages/Send/SendAddEditPage.xaml @@ -1,4 +1,4 @@ - + (AppResources.ThirtyDays, AppResources.ThirtyDays), new KeyValuePair(AppResources.Custom, AppResources.Custom), }; + + DeletionDateTimeViewModel = new DateTimeViewModel(AppResources.DeletionDate, AppResources.DeletionTime); + ExpirationDateTimeViewModel = new DateTimeViewModel(AppResources.ExpirationDate, AppResources.ExpirationTime) + { + OnDateChanged = date => + { + if (!_isOverridingPickers && !ExpirationDateTimeViewModel.Time.HasValue) + { + // auto-set time to current time upon setting date + ExpirationDateTimeViewModel.Time = DateTimeNow().TimeOfDay; + } + }, + OnTimeChanged = time => + { + if (!_isOverridingPickers && !ExpirationDateTimeViewModel.Date.HasValue) + { + // auto-set date to current date upon setting time + ExpirationDateTimeViewModel.Date = DateTime.Today; + } + }, + DatePlaceholder = "mm/dd/yyyy", + TimePlaceholder = "--:-- --" + }; + + AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger); } + public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } public Command TogglePasswordCommand { get; set; } public Command ToggleOptionsCommand { get; set; } public string SendId { get; set; } @@ -126,23 +149,14 @@ namespace Bit.App.Pages } } } - public DateTime DeletionDate - { - get => _deletionDate; - set => SetProperty(ref _deletionDate, value); - } - public TimeSpan DeletionTime - { - get => _deletionTime; - set => SetProperty(ref _deletionTime, value); - } public bool ShowOptions { get => _showOptions; set => SetProperty(ref _showOptions, value, additionalPropertyNames: new[] { - nameof(OptionsAccessilibityText) + nameof(OptionsAccessilibityText), + nameof(OptionsShowHideIcon) }); } public int ExpirationDateTypeSelectedIndex @@ -156,28 +170,7 @@ namespace Bit.App.Pages } } } - public DateTime? ExpirationDate - { - get => _expirationDate; - set - { - if (SetProperty(ref _expirationDate, value)) - { - ExpirationDateChanged(); - } - } - } - public TimeSpan? ExpirationTime - { - get => _expirationTime; - set - { - if (SetProperty(ref _expirationTime, value)) - { - ExpirationTimeChanged(); - } - } - } + public int? MaxAccessCount { get => _maxAccessCount; @@ -205,7 +198,7 @@ namespace Bit.App.Pages } public string FileName { - get => _fileName; + get => _fileName ?? AppResources.NoFileChosen; set { if (SetProperty(ref _fileName, value)) @@ -240,10 +233,13 @@ namespace Bit.App.Pages public bool IsFile => Send?.Type == SendType.File; public bool ShowDeletionCustomPickers => EditMode || DeletionDateTypeSelectedIndex == 6; public bool ShowExpirationCustomPickers => EditMode || ExpirationDateTypeSelectedIndex == 7; + public DateTimeViewModel DeletionDateTimeViewModel { get; } + public DateTimeViewModel ExpirationDateTimeViewModel { get; } public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye; public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; public string FileTypeAccessibilityLabel => IsFile ? AppResources.FileTypeIsSelected : AppResources.FileTypeIsNotSelected; public string TextTypeAccessibilityLabel => IsText ? AppResources.TextTypeIsSelected : AppResources.TextTypeIsNotSelected; + public string OptionsShowHideIcon => ShowOptions ? BitwardenIcons.ChevronUp : BitwardenIcons.AngleDown; public async Task InitAsync() { @@ -268,10 +264,8 @@ namespace Bit.App.Pages return false; } Send = await send.DecryptAsync(); - DeletionDate = Send.DeletionDate.ToLocalTime(); - DeletionTime = DeletionDate.TimeOfDay; - ExpirationDate = Send.ExpirationDate?.ToLocalTime(); - ExpirationTime = ExpirationDate?.TimeOfDay; + DeletionDateTimeViewModel.DateTime = Send.DeletionDate.ToLocalTime(); + ExpirationDateTimeViewModel.DateTime = Send.ExpirationDate?.ToLocalTime(); } else { @@ -280,8 +274,7 @@ namespace Bit.App.Pages { Type = Type.GetValueOrDefault(defaultType), }; - _deletionDate = DateTimeNow().AddDays(7); - _deletionTime = DeletionDate.TimeOfDay; + DeletionDateTimeViewModel.DateTime = DateTimeNow().AddDays(7); DeletionDateTypeSelectedIndex = 4; ExpirationDateTypeSelectedIndex = 0; } @@ -305,23 +298,22 @@ namespace Bit.App.Pages public void ClearExpirationDate() { _isOverridingPickers = true; - ExpirationDate = null; - ExpirationTime = null; + ExpirationDateTimeViewModel.DateTime = null; _isOverridingPickers = false; } private void UpdateSendData() { // filename - if (Send.File != null && FileName != null) + if (Send.File != null && _fileName != null) { - Send.File.FileName = FileName; + Send.File.FileName = _fileName; } // deletion date if (ShowDeletionCustomPickers) { - Send.DeletionDate = DeletionDate.Date.Add(DeletionTime).ToUniversalTime(); + Send.DeletionDate = DeletionDateTimeViewModel.DateTime.Value.ToUniversalTime(); } else { @@ -329,9 +321,9 @@ namespace Bit.App.Pages } // expiration date - if (ShowExpirationCustomPickers && ExpirationDate.HasValue && ExpirationTime.HasValue) + if (ShowExpirationCustomPickers && ExpirationDateTimeViewModel.DateTime.HasValue) { - Send.ExpirationDate = ExpirationDate.Value.Date.Add(ExpirationTime.Value).ToUniversalTime(); + Send.ExpirationDate = ExpirationDateTimeViewModel.DateTime.Value.ToUniversalTime(); } else if (_simpleExpirationDateTime.HasValue) { @@ -484,7 +476,7 @@ namespace Bit.App.Pages return; } - if (Page is SendAddEditPage sendPage && sendPage.OnClose != null) + if (Page is SendAddOnlyPage sendPage && sendPage.OnClose != null) { sendPage.OnClose(); return; @@ -625,24 +617,6 @@ namespace Bit.App.Pages } } - private void ExpirationDateChanged() - { - if (!_isOverridingPickers && !ExpirationTime.HasValue) - { - // auto-set time to current time upon setting date - ExpirationTime = DateTimeNow().TimeOfDay; - } - } - - private void ExpirationTimeChanged() - { - if (!_isOverridingPickers && !ExpirationDate.HasValue) - { - // auto-set date to current date upon setting time - ExpirationDate = DateTime.Today; - } - } - private void MaxAccessCountChanged() { Send.MaxAccessCount = _maxAccessCount; @@ -666,5 +640,10 @@ namespace Bit.App.Pages DateTimeKind.Local ); } + + internal void TriggerSendTextPropertyChanged() + { + Device.BeginInvokeOnMainThread(() => TriggerPropertyChanged(nameof(Send))); + } } } diff --git a/src/App/Pages/Send/SendAddOnlyOptionsView.xaml b/src/App/Pages/Send/SendAddOnlyOptionsView.xaml new file mode 100644 index 000000000..39528c14c --- /dev/null +++ b/src/App/Pages/Send/SendAddOnlyOptionsView.xaml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Pages/Send/SendAddOnlyOptionsView.xaml.cs b/src/App/Pages/Send/SendAddOnlyOptionsView.xaml.cs new file mode 100644 index 000000000..84829ea23 --- /dev/null +++ b/src/App/Pages/Send/SendAddOnlyOptionsView.xaml.cs @@ -0,0 +1,91 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Bit.App.Behaviors; +using Xamarin.CommunityToolkit.UI.Views; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class SendAddOnlyOptionsView : ContentView + { + public SendAddOnlyOptionsView() + { + InitializeComponent(); + } + + private SendAddEditPageViewModel ViewModel => BindingContext as SendAddEditPageViewModel; + + public void SetMainScrollView(ScrollView scrollView) + { + _notesEditor.Behaviors.Add(new EditorPreventAutoBottomScrollingOnFocusedBehavior { ParentScrollView = scrollView }); + } + + private void OnMaxAccessCountTextChanged(object sender, TextChangedEventArgs e) + { + if (ViewModel is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(e.NewTextValue)) + { + ViewModel.MaxAccessCount = null; + _maxAccessCountStepper.Value = 0; + return; + } + // accept only digits + if (!int.TryParse(e.NewTextValue, out int _)) + { + ((Entry)sender).Text = e.OldTextValue; + } + } + + protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + base.OnPropertyChanged(propertyName); + + if (propertyName == nameof(BindingContext) + && + ViewModel != null) + { + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + } + } + + private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (!_lazyDeletionDateTimePicker.IsLoaded + && + e.PropertyName == nameof(SendAddEditPageViewModel.ShowDeletionCustomPickers) + && + ViewModel.ShowDeletionCustomPickers) + { + _lazyDeletionDateTimePicker.LoadViewAsync(); + } + + if (!_lazyExpirationDateTimePicker.IsLoaded + && + e.PropertyName == nameof(SendAddEditPageViewModel.ShowExpirationCustomPickers) + && + ViewModel.ShowExpirationCustomPickers) + { + _lazyExpirationDateTimePicker.LoadViewAsync(); + } + } + } + + public class SendAddOnlyOptionsLazyView : LazyView + { + public ScrollView MainScrollView { get; set; } + + public override async ValueTask LoadViewAsync() + { + await base.LoadViewAsync(); + + if (Content is SendAddOnlyOptionsView optionsView) + { + optionsView.SetMainScrollView(MainScrollView); + } + } + } +} diff --git a/src/App/Pages/Send/SendAddOnlyPage.xaml b/src/App/Pages/Send/SendAddOnlyPage.xaml new file mode 100644 index 000000000..d3a0106ff --- /dev/null +++ b/src/App/Pages/Send/SendAddOnlyPage.xaml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Pages/Send/SendAddOnlyPage.xaml.cs b/src/App/Pages/Send/SendAddOnlyPage.xaml.cs new file mode 100644 index 000000000..821c9f817 --- /dev/null +++ b/src/App/Pages/Send/SendAddOnlyPage.xaml.cs @@ -0,0 +1,178 @@ +using System; +using System.Threading.Tasks; +using Bit.App.Models; +using Bit.App.Utilities; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + /// + /// This is a version of that is reduced for adding only and adapted + /// for performance for iOS Share extension. + /// + /// + /// This should NOT be used in Android. + /// + public partial class SendAddOnlyPage : BaseContentPage + { + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly LazyResolve _logger = new LazyResolve("logger"); + + private AppOptions _appOptions; + private SendAddEditPageViewModel _vm; + + public Action OnClose { get; set; } + public Action AfterSubmit { get; set; } + + public SendAddOnlyPage( + AppOptions appOptions = null, + string sendId = null, + SendType? type = null) + { + if (appOptions?.IosExtension != true) + { + throw new InvalidOperationException(nameof(SendAddOnlyPage) + " is only prepared to be used in iOS share extension"); + } + + _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); + _appOptions = appOptions; + InitializeComponent(); + _vm = BindingContext as SendAddEditPageViewModel; + _vm.Page = this; + _vm.SendId = sendId; + _vm.Type = appOptions?.CreateSend?.Item1 ?? type; + + if (_vm.IsText) + { + _nameEntry.ReturnType = ReturnType.Next; + _nameEntry.ReturnCommand = new Command(() => _textEditor.Focus()); + } + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + + try + { + if (!await AppHelpers.IsVaultTimeoutImmediateAsync()) + { + await _vaultTimeoutService.CheckVaultTimeoutAsync(); + } + if (await _vaultTimeoutService.IsLockedAsync()) + { + return; + } + await _vm.InitAsync(); + + if (!await _vm.LoadAsync()) + { + await CloseAsync(); + return; + } + + _accountAvatar?.OnAppearing(); + await Device.InvokeOnMainThreadAsync(async () => _vm.AvatarImageSource = await GetAvatarImageSourceAsync()); + + await HandleCreateRequest(); + if (string.IsNullOrWhiteSpace(_vm.Send?.Name)) + { + RequestFocus(_nameEntry); + } + AdjustToolbar(); + } + catch (Exception ex) + { + _logger.Value.Exception(ex); + await CloseAsync(); + } + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _accountAvatar?.OnDisappearing(); + } + + private async Task CloseAsync() + { + if (OnClose is null) + { + await Navigation.PopModalAsync(); + } + else + { + OnClose(); + } + } + + private async void Save_Clicked(object sender, EventArgs e) + { + if (DoOnce()) + { + var submitted = await _vm.SubmitAsync(); + if (submitted) + { + AfterSubmit?.Invoke(); + } + } + } + + private async void Close_Clicked(object sender, EventArgs e) + { + if (DoOnce()) + { + await CloseAsync(); + } + } + + private void AdjustToolbar() + { + _saveItem.IsEnabled = _vm.SendEnabled; + } + + private Task HandleCreateRequest() + { + if (_appOptions?.CreateSend == null) + { + return Task.CompletedTask; + } + + _vm.IsAddFromShare = true; + _vm.CopyInsteadOfShareAfterSaving = _appOptions.CopyInsteadOfShareAfterSaving; + + var name = _appOptions.CreateSend.Item2; + _vm.Send.Name = name; + + var type = _appOptions.CreateSend.Item1; + if (type == SendType.File) + { + _vm.FileData = _appOptions.CreateSend.Item3; + _vm.FileName = name; + } + else + { + var text = _appOptions.CreateSend.Item4; + _vm.Send.Text.Text = text; + _vm.TriggerSendTextPropertyChanged(); + } + _appOptions.CreateSend = null; + + return Task.CompletedTask; + } + + void OptionsHeader_Tapped(object sender, EventArgs e) + { + _vm.ToggleOptionsCommand.Execute(null); + + if (!_lazyOptionsView.IsLoaded) + { + _lazyOptionsView.MainScrollView = _scrollView; + _lazyOptionsView.LoadViewAsync(); + } + } + } +} diff --git a/src/App/Pages/Settings/OptionsPage.xaml b/src/App/Pages/Settings/OptionsPage.xaml index 8d37074c6..8c4e3081b 100644 --- a/src/App/Pages/Settings/OptionsPage.xaml +++ b/src/App/Pages/Settings/OptionsPage.xaml @@ -83,31 +83,31 @@ @@ -117,16 +117,16 @@