diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml
index 04d9374b5..6dd2e3c4f 100644
--- a/.github/workflows/crowdin-pull.yml
+++ b/.github/workflows/crowdin-pull.yml
@@ -24,17 +24,10 @@ jobs:
- name: Retrieve secrets
id: retrieve-secrets
- env:
- KEYVAULT: bitwarden-prod-kv
- SECRETS: |
- crowdin-api-token
- run: |
- for i in ${SECRETS//,/ }
- do
- VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv)
- echo "::add-mask::$VALUE"
- echo "::set-output name=$i::$VALUE"
- done
+ uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
+ with:
+ keyvault: "bitwarden-prod-kv"
+ secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Download translations
uses: crowdin/github-action@12143a68c213f3c6d9913c9e5023224f7231face
@@ -47,10 +40,12 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
- github_user_name: "github-actions"
- github_user_email: "<>"
+ github_user_name: "bitwarden-devops-bot"
+ github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
commit_message: "Autosync the updated translations"
localization_branch_name: crowdin-auto-sync
create_pull_request: true
pull_request_title: "Autosync Crowdin Translations"
pull_request_body: "Autosync the updated translations"
+ gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
+ gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml
index fefb1f191..b12756bff 100644
--- a/.github/workflows/version-auto-bump.yml
+++ b/.github/workflows/version-auto-bump.yml
@@ -2,39 +2,38 @@
name: Version Auto Bump
on:
- release:
- types: [published]
+ push:
+ tags:
+ - v**
jobs:
-
setup:
name: "Setup"
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
outputs:
version_number: ${{ steps.version.outputs.new-version }}
steps:
- name: Checkout Branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- - name: Get version to bump
+ - name: Calculate bumped version
id: version
env:
- RELEASE_TAG: ${{ github.event.release.tag }}
+ RELEASE_TAG: ${{ github.ref }}
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
+ CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\1/')
+ CURR_PATCH=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\2/')
+ echo "Current Major: $CURR_MAJOR"
+ echo "Current Patch: $CURR_PATCH"
+ NEW_PATCH=$((CURR_PATCH+1))
+ NEW_VER=$CURR_MAJOR.$NEW_PATCH
+ echo "New Version: $NEW_VER"
echo "::set-output name=new-version::$NEW_VER"
trigger_version_bump:
name: "Trigger version bump workflow"
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
needs:
- setup
steps:
@@ -45,13 +44,10 @@ jobs:
- 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"
+ uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
+ with:
+ keyvault: "bitwarden-prod-kv"
+ secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Call GitHub API to trigger workflow bump
env:
diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml
index b3c5a58ef..b2cb101f4 100644
--- a/.github/workflows/version-bump.yml
+++ b/.github/workflows/version-bump.yml
@@ -16,6 +16,26 @@ jobs:
- name: Checkout Branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
+ - name: Login to Azure - Prod Subscription
+ uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
+ with:
+ creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
+
+ - name: Retrieve secrets
+ id: retrieve-secrets
+ uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
+ with:
+ keyvault: "bitwarden-prod-kv"
+ secrets: "github-gpg-private-key, github-gpg-private-key-passphrase"
+
+ - name: Import GPG key
+ uses: crazy-max/ghaction-import-gpg@c8bb57c57e8df1be8c73ff3d59deab1dbc00e0d1
+ with:
+ gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
+ passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
+ git_user_signingkey: true
+ git_commit_gpgsign: true
+
- name: Create Version Branch
run: |
git switch -c version_bump_${{ github.event.inputs.version_number }}
@@ -52,8 +72,8 @@ jobs:
- 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]"
+ git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
+ git config --local user.name "bitwarden-devops-bot"
- name: Check if version changed
id: version-changed
diff --git a/src/Android/Accessibility/AccessibilityHelpers.cs b/src/Android/Accessibility/AccessibilityHelpers.cs
index 81d7fa65f..cfc1bc77f 100644
--- a/src/Android/Accessibility/AccessibilityHelpers.cs
+++ b/src/Android/Accessibility/AccessibilityHelpers.cs
@@ -367,7 +367,7 @@ namespace Bit.Droid.Accessibility
public static string GetUri(AccessibilityNodeInfo root)
{
- var uri = string.Concat(Constants.AndroidAppProtocol, root.PackageName);
+ var uri = string.Concat(Core.Constants.AndroidAppProtocol, root.PackageName);
if (SupportedBrowsers.ContainsKey(root.PackageName))
{
var browser = SupportedBrowsers[root.PackageName];
diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj
index af216e83f..7abf7d6dd 100644
--- a/src/Android/Android.csproj
+++ b/src/Android/Android.csproj
@@ -152,6 +152,10 @@
+
+
+
+
@@ -217,6 +221,10 @@
+
+
+
+
diff --git a/src/Android/Autofill/AutofillService.cs b/src/Android/Autofill/AutofillService.cs
index 97bc07ae4..3becfe4ac 100644
--- a/src/Android/Autofill/AutofillService.cs
+++ b/src/Android/Autofill/AutofillService.cs
@@ -134,7 +134,7 @@ namespace Bit.Droid.Autofill
{
case CipherType.Login:
intent.PutExtra("autofillFrameworkName", parser.Uri
- .Replace(Constants.AndroidAppProtocol, string.Empty)
+ .Replace(Core.Constants.AndroidAppProtocol, string.Empty)
.Replace("https://", string.Empty)
.Replace("http://", string.Empty));
intent.PutExtra("autofillFrameworkUri", parser.Uri);
diff --git a/src/Android/Autofill/Parser.cs b/src/Android/Autofill/Parser.cs
index 45a2fdb0b..4bc2ebca5 100644
--- a/src/Android/Autofill/Parser.cs
+++ b/src/Android/Autofill/Parser.cs
@@ -48,7 +48,7 @@ namespace Bit.Droid.Autofill
}
else
{
- _uri = string.Concat(Constants.AndroidAppProtocol, PackageName);
+ _uri = string.Concat(Core.Constants.AndroidAppProtocol, PackageName);
}
return _uri;
}
diff --git a/src/Android/Constants.cs b/src/Android/Constants.cs
new file mode 100644
index 000000000..398bfb708
--- /dev/null
+++ b/src/Android/Constants.cs
@@ -0,0 +1,7 @@
+namespace Bit.Droid
+{
+ public static class Constants
+ {
+ public const string PACKAGE_NAME = "com.x8bit.bitwarden";
+ }
+}
diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs
index b5d5a0ff5..cffc12ac0 100644
--- a/src/Android/MainActivity.cs
+++ b/src/Android/MainActivity.cs
@@ -12,6 +12,7 @@ using Android.Runtime;
using Android.Views;
using Bit.App.Abstractions;
using Bit.App.Models;
+using Bit.App.Resources;
using Bit.App.Utilities;
using Bit.Core;
using Bit.Core.Abstractions;
@@ -19,6 +20,8 @@ using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Droid.Receivers;
using Bit.Droid.Utilities;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
using Xamarin.Essentials;
using ZXing.Net.Mobile.Android;
using FileProvider = AndroidX.Core.Content.FileProvider;
@@ -33,11 +36,13 @@ namespace Bit.Droid
public class MainActivity : Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
private IDeviceActionService _deviceActionService;
+ private IFileService _fileService;
private IMessagingService _messagingService;
private IBroadcasterService _broadcasterService;
private IStateService _stateService;
private IAppIdService _appIdService;
private IEventService _eventService;
+ private IPushNotificationListenerService _pushNotificationListenerService;
private ILogger _logger;
private PendingIntent _eventUploadPendingIntent;
private AppOptions _appOptions;
@@ -55,11 +60,13 @@ namespace Bit.Droid
StrictMode.SetThreadPolicy(policy);
_deviceActionService = ServiceContainer.Resolve("deviceActionService");
+ _fileService = ServiceContainer.Resolve();
_messagingService = ServiceContainer.Resolve("messagingService");
_broadcasterService = ServiceContainer.Resolve("broadcasterService");
_stateService = ServiceContainer.Resolve("stateService");
_appIdService = ServiceContainer.Resolve("appIdService");
_eventService = ServiceContainer.Resolve("eventService");
+ _pushNotificationListenerService = ServiceContainer.Resolve();
_logger = ServiceContainer.Resolve("logger");
TabLayoutResource = Resource.Layout.Tabbar;
@@ -86,6 +93,7 @@ namespace Bit.Droid
Xamarin.Essentials.Platform.Init(this, savedInstanceState);
Xamarin.Forms.Forms.Init(this, savedInstanceState);
_appOptions = GetOptions();
+ CreateNotificationChannel();
LoadApplication(new App.App(_appOptions));
DisableAndroidFontScale();
@@ -143,6 +151,15 @@ namespace Bit.Droid
AndroidHelpers.SetPreconfiguredRestrictionSettingsAsync(this)
.GetAwaiter()
.GetResult();
+
+ if (Intent?.GetStringExtra(Core.Constants.NotificationData) is string notificationDataJson)
+ {
+ var notificationType = JToken.Parse(notificationDataJson).SelectToken(Core.Constants.NotificationDataType);
+ if (notificationType.ToString() == PasswordlessNotificationData.TYPE)
+ {
+ _pushNotificationListenerService.OnNotificationTapped(JsonConvert.DeserializeObject(notificationDataJson)).FireAndForget();
+ }
+ }
}
protected override void OnNewIntent(Intent intent)
@@ -196,13 +213,13 @@ namespace Bit.Droid
public async override void OnRequestPermissionsResult(int requestCode, string[] permissions,
[GeneratedEnum] Permission[] grantResults)
{
- if (requestCode == Constants.SelectFilePermissionRequestCode)
+ if (requestCode == Core.Constants.SelectFilePermissionRequestCode)
{
if (grantResults.Any(r => r != Permission.Granted))
{
_messagingService.Send("selectFileCameraPermissionDenied");
}
- await _deviceActionService.SelectFileAsync();
+ await _fileService.SelectFileAsync();
}
else
{
@@ -215,7 +232,7 @@ namespace Bit.Droid
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
if (resultCode == Result.Ok &&
- (requestCode == Constants.SelectFileRequestCode || requestCode == Constants.SaveFileRequestCode))
+ (requestCode == Core.Constants.SelectFileRequestCode || requestCode == Core.Constants.SaveFileRequestCode))
{
Android.Net.Uri uri = null;
string fileName = null;
@@ -237,7 +254,7 @@ namespace Bit.Droid
return;
}
- if (requestCode == Constants.SaveFileRequestCode)
+ if (requestCode == Core.Constants.SaveFileRequestCode)
{
_messagingService.Send("selectSaveFileResult",
new Tuple(uri.ToString(), fileName));
@@ -407,6 +424,25 @@ namespace Bit.Droid
await _eventService.UploadEventsAsync();
}
+ private void CreateNotificationChannel()
+ {
+#if !FDROID
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ {
+ // Notification channels are new in API 26 (and not a part of the
+ // support library). There is no need to create a notification
+ // channel on older versions of Android.
+ return;
+ }
+
+ var channel = new NotificationChannel(Core.Constants.AndroidNotificationChannelId, AppResources.AllNotifications, NotificationImportance.Default);
+ if(GetSystemService(NotificationService) is NotificationManager notificationManager)
+ {
+ notificationManager.CreateNotificationChannel(channel);
+ }
+#endif
+ }
+
private void DisableAndroidFontScale()
{
try
diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs
index fe15ebb59..3124842e0 100644
--- a/src/Android/MainApplication.cs
+++ b/src/Android/MainApplication.cs
@@ -47,8 +47,8 @@ namespace Bit.Droid
{
RegisterLocalServices();
var deviceActionService = ServiceContainer.Resolve("deviceActionService");
- ServiceContainer.Init(deviceActionService.DeviceUserAgent, Constants.ClearCiphersCacheKey,
- Constants.AndroidAllClearCipherCacheKeys);
+ ServiceContainer.Init(deviceActionService.DeviceUserAgent, Core.Constants.ClearCiphersCacheKey,
+ Core.Constants.AndroidAllClearCipherCacheKeys);
InitializeAppSetup();
// TODO: Update when https://github.com/bitwarden/mobile/pull/1662 gets merged
@@ -73,7 +73,8 @@ namespace Bit.Droid
ServiceContainer.Resolve("stateService"),
ServiceContainer.Resolve("platformUtilsService"),
ServiceContainer.Resolve("authService"),
- ServiceContainer.Resolve("logger"));
+ ServiceContainer.Resolve("logger"),
+ ServiceContainer.Resolve("messagingService"));
ServiceContainer.Register("accountsManager", accountsManager);
var cipherHelper = new CipherHelper(
@@ -148,8 +149,9 @@ namespace Bit.Droid
var stateMigrationService =
new StateMigrationService(liteDbStorage, preferencesStorage, secureStorageService);
var clipboardService = new ClipboardService(stateService);
- var deviceActionService = new DeviceActionService(clipboardService, stateService, messagingService,
- broadcasterService, () => ServiceContainer.Resolve("eventService"));
+ var deviceActionService = new DeviceActionService(stateService, messagingService);
+ var fileService = new FileService(stateService, broadcasterService);
+ var autofillHandler = new AutofillHandler(stateService, messagingService, clipboardService, new LazyResolve());
var platformUtilsService = new MobilePlatformUtilsService(deviceActionService, clipboardService,
messagingService, broadcasterService);
var biometricService = new BiometricService();
@@ -168,6 +170,8 @@ namespace Bit.Droid
ServiceContainer.Register("stateMigrationService", stateMigrationService);
ServiceContainer.Register("clipboardService", clipboardService);
ServiceContainer.Register("deviceActionService", deviceActionService);
+ ServiceContainer.Register(fileService);
+ ServiceContainer.Register(autofillHandler);
ServiceContainer.Register("platformUtilsService", platformUtilsService);
ServiceContainer.Register("biometricService", biometricService);
ServiceContainer.Register("cryptoFunctionService", cryptoFunctionService);
diff --git a/src/Android/Properties/AndroidManifest.xml b/src/Android/Properties/AndroidManifest.xml
index be2660f0b..84c881737 100644
--- a/src/Android/Properties/AndroidManifest.xml
+++ b/src/Android/Properties/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/src/Android/Push/FirebaseMessagingService.cs b/src/Android/Push/FirebaseMessagingService.cs
index 676390aef..887c8ac44 100644
--- a/src/Android/Push/FirebaseMessagingService.cs
+++ b/src/Android/Push/FirebaseMessagingService.cs
@@ -1,7 +1,9 @@
#if !FDROID
+using System;
using Android.App;
using Bit.App.Abstractions;
using Bit.Core.Abstractions;
+using Bit.Core.Services;
using Bit.Core.Utilities;
using Firebase.Messaging;
using Newtonsoft.Json;
@@ -16,34 +18,41 @@ namespace Bit.Droid.Push
{
public async override void OnNewToken(string token)
{
- var stateService = ServiceContainer.Resolve("stateService");
- var pushNotificationService = ServiceContainer.Resolve("pushNotificationService");
+ try {
+ var stateService = ServiceContainer.Resolve("stateService");
+ var pushNotificationService = ServiceContainer.Resolve("pushNotificationService");
- await stateService.SetPushRegisteredTokenAsync(token);
- await pushNotificationService.RegisterAsync();
+ await stateService.SetPushRegisteredTokenAsync(token);
+ await pushNotificationService.RegisterAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.Exception(ex);
+ }
}
public async override void OnMessageReceived(RemoteMessage message)
{
- if (message?.Data == null)
- {
- return;
- }
- var data = message.Data.ContainsKey("data") ? message.Data["data"] : null;
- if (data == null)
- {
- return;
- }
try
{
+ if (message?.Data == null)
+ {
+ return;
+ }
+ var data = message.Data.ContainsKey("data") ? message.Data["data"] : null;
+ if (data == null)
+ {
+ return;
+ }
+
var obj = JObject.Parse(data);
var listener = ServiceContainer.Resolve(
"pushNotificationListenerService");
await listener.OnMessageAsync(obj, Device.Android);
}
- catch (JsonReaderException ex)
+ catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine(ex.ToString());
+ Logger.Instance.Exception(ex);
}
}
}
diff --git a/src/Android/Receivers/NotificationDismissReceiver.cs b/src/Android/Receivers/NotificationDismissReceiver.cs
new file mode 100644
index 000000000..43f69ea62
--- /dev/null
+++ b/src/Android/Receivers/NotificationDismissReceiver.cs
@@ -0,0 +1,41 @@
+using Android.Content;
+using Bit.App.Abstractions;
+using Bit.App.Models;
+using Bit.App.Services;
+using Bit.Core;
+using Bit.Core.Abstractions;
+using Bit.Core.Services;
+using Bit.Core.Utilities;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using CoreConstants = Bit.Core.Constants;
+
+namespace Bit.Droid.Receivers
+{
+ [BroadcastReceiver(Name = Constants.PACKAGE_NAME + "." + nameof(NotificationDismissReceiver), Exported = false)]
+ public class NotificationDismissReceiver : BroadcastReceiver
+ {
+ private readonly LazyResolve _pushNotificationListenerService = new LazyResolve();
+ private readonly LazyResolve _logger = new LazyResolve();
+
+ public override void OnReceive(Context context, Intent intent)
+ {
+ try
+ {
+ if (intent?.GetStringExtra(CoreConstants.NotificationData) is string notificationDataJson)
+ {
+ var notificationType = JToken.Parse(notificationDataJson).SelectToken(CoreConstants.NotificationDataType);
+ if (notificationType.ToString() == PasswordlessNotificationData.TYPE)
+ {
+ _pushNotificationListenerService.Value.OnNotificationDismissed(JsonConvert.DeserializeObject(notificationDataJson)).FireAndForget();
+ }
+ }
+ }
+ catch (System.Exception ex)
+ {
+ _logger.Value.Exception(ex);
+ }
+ }
+ }
+}
+
diff --git a/src/Android/Resources/drawable/ic_notification.xml b/src/Android/Resources/drawable/ic_notification.xml
new file mode 100644
index 000000000..ed92d0ce8
--- /dev/null
+++ b/src/Android/Resources/drawable/ic_notification.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/src/Android/Resources/xml/accessibilityservice.xml b/src/Android/Resources/xml/accessibilityservice.xml
index e65df58b1..dcb4a1f02 100644
--- a/src/Android/Resources/xml/accessibilityservice.xml
+++ b/src/Android/Resources/xml/accessibilityservice.xml
@@ -6,4 +6,5 @@
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:notificationTimeout="100"
- android:canRetrieveWindowContent="true"/>
\ No newline at end of file
+ android:canRetrieveWindowContent="true"
+ android:isAccessibilityTool="false"/>
\ No newline at end of file
diff --git a/src/Android/Services/AndroidPushNotificationService.cs b/src/Android/Services/AndroidPushNotificationService.cs
index e871393f6..6c5383f8f 100644
--- a/src/Android/Services/AndroidPushNotificationService.cs
+++ b/src/Android/Services/AndroidPushNotificationService.cs
@@ -1,10 +1,21 @@
#if !FDROID
using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
+using Android.App;
+using Android.Content;
+using Android.OS;
using AndroidX.Core.App;
using Bit.App.Abstractions;
+using Bit.App.Models;
+using Bit.Core;
using Bit.Core.Abstractions;
+using Bit.Droid.Receivers;
+using Bit.Droid.Utilities;
+using Newtonsoft.Json;
using Xamarin.Forms;
+using static Xamarin.Essentials.Platform;
+using Intent = Android.Content.Intent;
namespace Bit.Droid.Services
{
@@ -23,6 +34,11 @@ namespace Bit.Droid.Services
public bool IsRegisteredForPush => NotificationManagerCompat.From(Android.App.Application.Context)?.AreNotificationsEnabled() ?? false;
+ public Task AreNotificationsSettingsEnabledAsync()
+ {
+ return Task.FromResult(IsRegisteredForPush);
+ }
+
public async Task GetTokenAsync()
{
return await _stateService.GetPushCurrentTokenAsync();
@@ -47,6 +63,50 @@ namespace Bit.Droid.Services
// Do we ever need to unregister?
return Task.FromResult(0);
}
+
+ public void DismissLocalNotification(string notificationId)
+ {
+ if (int.TryParse(notificationId, out int intNotificationId))
+ {
+ var notificationManager = NotificationManagerCompat.From(Android.App.Application.Context);
+ notificationManager.Cancel(intNotificationId);
+ }
+ }
+
+ public void SendLocalNotification(string title, string message, BaseNotificationData data)
+ {
+ if (string.IsNullOrEmpty(data.Id))
+ {
+ throw new ArgumentNullException("notificationId cannot be null or empty.");
+ }
+
+ var context = Android.App.Application.Context;
+ var intent = new Intent(context, typeof(MainActivity));
+ intent.PutExtra(Bit.Core.Constants.NotificationData, JsonConvert.SerializeObject(data));
+ var pendingIntentFlags = AndroidHelpers.AddPendingIntentMutabilityFlag(PendingIntentFlags.UpdateCurrent, true);
+ var pendingIntent = PendingIntent.GetActivity(context, 20220801, intent, pendingIntentFlags);
+
+ var deleteIntent = new Intent(context, typeof(NotificationDismissReceiver));
+ deleteIntent.PutExtra(Bit.Core.Constants.NotificationData, JsonConvert.SerializeObject(data));
+ var deletePendingIntent = PendingIntent.GetBroadcast(context, 20220802, deleteIntent, pendingIntentFlags);
+
+ var builder = new NotificationCompat.Builder(context, Bit.Core.Constants.AndroidNotificationChannelId)
+ .SetContentIntent(pendingIntent)
+ .SetContentTitle(title)
+ .SetContentText(message)
+ .SetSmallIcon(Resource.Drawable.ic_notification)
+ .SetColor((int)Android.Graphics.Color.White)
+ .SetDeleteIntent(deletePendingIntent)
+ .SetAutoCancel(true);
+
+ if (data is PasswordlessNotificationData passwordlessNotificationData && passwordlessNotificationData.TimeoutInMinutes > 0)
+ {
+ builder.SetTimeoutAfter(passwordlessNotificationData.TimeoutInMinutes * 60000);
+ }
+
+ var notificationManager = NotificationManagerCompat.From(context);
+ notificationManager.Notify(int.Parse(data.Id), builder.Build());
+ }
}
}
#endif
diff --git a/src/Android/Services/AutofillHandler.cs b/src/Android/Services/AutofillHandler.cs
new file mode 100644
index 000000000..9cfa5ec9e
--- /dev/null
+++ b/src/Android/Services/AutofillHandler.cs
@@ -0,0 +1,210 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Android.App;
+using Android.App.Assist;
+using Android.Content;
+using Android.OS;
+using Android.Provider;
+using Android.Views.Autofill;
+using Bit.Core.Abstractions;
+using Bit.Core.Enums;
+using Bit.Core.Models.View;
+using Bit.Core.Utilities;
+using Bit.Droid.Autofill;
+using Plugin.CurrentActivity;
+
+namespace Bit.Droid.Services
+{
+ public class AutofillHandler : IAutofillHandler
+ {
+ private readonly IStateService _stateService;
+ private readonly IMessagingService _messagingService;
+ private readonly IClipboardService _clipboardService;
+ private readonly LazyResolve _eventService;
+
+ public AutofillHandler(IStateService stateService,
+ IMessagingService messagingService,
+ IClipboardService clipboardService,
+ LazyResolve eventService)
+ {
+ _stateService = stateService;
+ _messagingService = messagingService;
+ _clipboardService = clipboardService;
+ _eventService = eventService;
+ }
+
+ public bool AutofillServiceEnabled()
+ {
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ {
+ return false;
+ }
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var afm = (AutofillManager)activity.GetSystemService(
+ Java.Lang.Class.FromType(typeof(AutofillManager)));
+ return afm.IsEnabled && afm.HasEnabledAutofillServices;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public bool SupportsAutofillService()
+ {
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ {
+ return false;
+ }
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var type = Java.Lang.Class.FromType(typeof(AutofillManager));
+ var manager = activity.GetSystemService(type) as AutofillManager;
+ return manager.IsAutofillSupported;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public void Autofill(CipherView cipher)
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ if (activity == null)
+ {
+ return;
+ }
+ if (activity.Intent?.GetBooleanExtra("autofillFramework", false) ?? false)
+ {
+ if (cipher == null)
+ {
+ activity.SetResult(Result.Canceled);
+ activity.Finish();
+ return;
+ }
+ var structure = activity.Intent.GetParcelableExtra(
+ AutofillManager.ExtraAssistStructure) as AssistStructure;
+ if (structure == null)
+ {
+ activity.SetResult(Result.Canceled);
+ activity.Finish();
+ return;
+ }
+ var parser = new Parser(structure, activity.ApplicationContext);
+ parser.Parse();
+ if ((!parser.FieldCollection?.Fields?.Any() ?? true) || string.IsNullOrWhiteSpace(parser.Uri))
+ {
+ activity.SetResult(Result.Canceled);
+ activity.Finish();
+ return;
+ }
+ var task = CopyTotpAsync(cipher);
+ var dataset = AutofillHelpers.BuildDataset(activity, parser.FieldCollection, new FilledItem(cipher));
+ var replyIntent = new Intent();
+ replyIntent.PutExtra(AutofillManager.ExtraAuthenticationResult, dataset);
+ activity.SetResult(Result.Ok, replyIntent);
+ activity.Finish();
+ var eventTask = _eventService.Value.CollectAsync(EventType.Cipher_ClientAutofilled, cipher.Id);
+ }
+ else
+ {
+ var data = new Intent();
+ if (cipher?.Login == null)
+ {
+ data.PutExtra("canceled", "true");
+ }
+ else
+ {
+ var task = CopyTotpAsync(cipher);
+ data.PutExtra("uri", cipher.Login.Uri);
+ data.PutExtra("username", cipher.Login.Username);
+ data.PutExtra("password", cipher.Login.Password);
+ }
+ if (activity.Parent == null)
+ {
+ activity.SetResult(Result.Ok, data);
+ }
+ else
+ {
+ activity.Parent.SetResult(Result.Ok, data);
+ }
+ activity.Finish();
+ _messagingService.Send("finishMainActivity");
+ if (cipher != null)
+ {
+ var eventTask = _eventService.Value.CollectAsync(EventType.Cipher_ClientAutofilled, cipher.Id);
+ }
+ }
+ }
+
+ public void CloseAutofill()
+ {
+ Autofill(null);
+ }
+
+ public bool AutofillAccessibilityServiceRunning()
+ {
+ var enabledServices = Settings.Secure.GetString(Application.Context.ContentResolver,
+ Settings.Secure.EnabledAccessibilityServices);
+ return Application.Context.PackageName != null &&
+ (enabledServices?.Contains(Application.Context.PackageName) ?? false);
+ }
+
+ public bool AutofillAccessibilityOverlayPermitted()
+ {
+ return Accessibility.AccessibilityHelpers.OverlayPermitted();
+ }
+
+
+
+ public void DisableAutofillService()
+ {
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var type = Java.Lang.Class.FromType(typeof(AutofillManager));
+ var manager = activity.GetSystemService(type) as AutofillManager;
+ manager.DisableAutofillServices();
+ }
+ catch { }
+ }
+
+ public bool AutofillServicesEnabled()
+ {
+ if (Build.VERSION.SdkInt <= BuildVersionCodes.M)
+ {
+ // Android 5-6: Both accessibility & overlay are required or nothing happens
+ return AutofillAccessibilityServiceRunning() && AutofillAccessibilityOverlayPermitted();
+ }
+ if (Build.VERSION.SdkInt == BuildVersionCodes.N)
+ {
+ // Android 7: Only accessibility is required (overlay is optional when using quick-action tile)
+ return AutofillAccessibilityServiceRunning();
+ }
+ // Android 8+: Either autofill or accessibility is required
+ return AutofillServiceEnabled() || AutofillAccessibilityServiceRunning();
+ }
+
+ private async Task CopyTotpAsync(CipherView cipher)
+ {
+ if (!string.IsNullOrWhiteSpace(cipher?.Login?.Totp))
+ {
+ var autoCopyDisabled = await _stateService.GetDisableAutoTotpCopyAsync();
+ var canAccessPremium = await _stateService.CanAccessPremiumAsync();
+ if ((canAccessPremium || cipher.OrganizationUseTotp) && !autoCopyDisabled.GetValueOrDefault())
+ {
+ var totpService = ServiceContainer.Resolve("totpService");
+ var totp = await totpService.GetCodeAsync(cipher.Login.Totp);
+ if (totp != null)
+ {
+ await _clipboardService.CopyTextAsync(totp);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs
index c981844ca..2ddee1245 100644
--- a/src/Android/Services/DeviceActionService.cs
+++ b/src/Android/Services/DeviceActionService.cs
@@ -1,11 +1,6 @@
using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
using System.Threading.Tasks;
-using Android;
using Android.App;
-using Android.App.Assist;
using Android.Content;
using Android.Content.PM;
using Android.Nfc;
@@ -14,20 +9,13 @@ using Android.Provider;
using Android.Text;
using Android.Text.Method;
using Android.Views;
-using Android.Views.Autofill;
using Android.Views.InputMethods;
-using Android.Webkit;
using Android.Widget;
-using AndroidX.Core.App;
-using AndroidX.Core.Content;
using Bit.App.Abstractions;
using Bit.App.Resources;
-using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
-using Bit.Core.Models.View;
using Bit.Core.Utilities;
-using Bit.Droid.Autofill;
using Bit.Droid.Utilities;
using Plugin.CurrentActivity;
@@ -35,38 +23,20 @@ namespace Bit.Droid.Services
{
public class DeviceActionService : IDeviceActionService
{
- private readonly IClipboardService _clipboardService;
private readonly IStateService _stateService;
private readonly IMessagingService _messagingService;
- private readonly IBroadcasterService _broadcasterService;
- private readonly Func _eventServiceFunc;
private AlertDialog _progressDialog;
object _progressDialogLock = new object();
- private bool _cameraPermissionsDenied;
private Toast _toast;
private string _userAgent;
public DeviceActionService(
- IClipboardService clipboardService,
IStateService stateService,
- IMessagingService messagingService,
- IBroadcasterService broadcasterService,
- Func eventServiceFunc)
+ IMessagingService messagingService)
{
- _clipboardService = clipboardService;
_stateService = stateService;
_messagingService = messagingService;
- _broadcasterService = broadcasterService;
- _eventServiceFunc = eventServiceFunc;
-
- _broadcasterService.Subscribe(nameof(DeviceActionService), (message) =>
- {
- if (message.Command == "selectFileCameraPermissionDenied")
- {
- _cameraPermissionsDenied = true;
- }
- });
}
public string DeviceUserAgent
@@ -212,184 +182,6 @@ namespace Bit.Droid.Services
return true;
}
- public bool OpenFile(byte[] fileData, string id, string fileName)
- {
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var intent = BuildOpenFileIntent(fileData, fileName);
- if (intent == null)
- {
- return false;
- }
- activity.StartActivity(intent);
- return true;
- }
- catch { }
- return false;
- }
-
- public bool CanOpenFile(string fileName)
- {
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var intent = BuildOpenFileIntent(new byte[0], string.Concat("opentest_", fileName));
- if (intent == null)
- {
- return false;
- }
- var activities = activity.PackageManager.QueryIntentActivities(intent,
- PackageInfoFlags.MatchDefaultOnly);
- return (activities?.Count ?? 0) > 0;
- }
- catch { }
- return false;
- }
-
- private Intent BuildOpenFileIntent(byte[] fileData, string fileName)
- {
- var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower());
- if (extension == null)
- {
- return null;
- }
- var mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension);
- if (mimeType == null)
- {
- return null;
- }
-
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var cachePath = activity.CacheDir;
- var filePath = Path.Combine(cachePath.Path, fileName);
- File.WriteAllBytes(filePath, fileData);
- var file = new Java.IO.File(cachePath, fileName);
- if (!file.IsFile)
- {
- return null;
- }
-
- try
- {
- var intent = new Intent(Intent.ActionView);
- var uri = FileProvider.GetUriForFile(activity.ApplicationContext,
- "com.x8bit.bitwarden.fileprovider", file);
- intent.SetDataAndType(uri, mimeType);
- intent.SetFlags(ActivityFlags.GrantReadUriPermission);
- return intent;
- }
- catch { }
- return null;
- }
-
- public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri)
- {
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
-
- if (contentUri != null)
- {
- var uri = Android.Net.Uri.Parse(contentUri);
- var stream = activity.ContentResolver.OpenOutputStream(uri);
- // Using java bufferedOutputStream due to this issue:
- // https://github.com/xamarin/xamarin-android/issues/3498
- var javaStream = new Java.IO.BufferedOutputStream(stream);
- javaStream.Write(fileData);
- javaStream.Flush();
- javaStream.Close();
- return true;
- }
-
- // Prompt for location to save file
- var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower());
- if (extension == null)
- {
- return false;
- }
-
- string mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension);
- if (mimeType == null)
- {
- // Unable to identify so fall back to generic "any" type
- mimeType = "*/*";
- }
-
- var intent = new Intent(Intent.ActionCreateDocument);
- intent.SetType(mimeType);
- intent.AddCategory(Intent.CategoryOpenable);
- intent.PutExtra(Intent.ExtraTitle, fileName);
-
- activity.StartActivityForResult(intent, Constants.SaveFileRequestCode);
- return true;
- }
- catch (Exception ex)
- {
- System.Diagnostics.Debug.WriteLine(">>> {0}: {1}", ex.GetType(), ex.StackTrace);
- }
- return false;
- }
-
- public async Task ClearCacheAsync()
- {
- try
- {
- DeleteDir(CrossCurrentActivity.Current.Activity.CacheDir);
- await _stateService.SetLastFileCacheClearAsync(DateTime.UtcNow);
- }
- catch (Exception) { }
- }
-
- public Task SelectFileAsync()
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var hasStorageWritePermission = !_cameraPermissionsDenied &&
- HasPermission(Manifest.Permission.WriteExternalStorage);
- var additionalIntents = new List();
- if (activity.PackageManager.HasSystemFeature(PackageManager.FeatureCamera))
- {
- var hasCameraPermission = !_cameraPermissionsDenied && HasPermission(Manifest.Permission.Camera);
- if (!_cameraPermissionsDenied && !hasStorageWritePermission)
- {
- AskPermission(Manifest.Permission.WriteExternalStorage);
- return Task.FromResult(0);
- }
- if (!_cameraPermissionsDenied && !hasCameraPermission)
- {
- AskPermission(Manifest.Permission.Camera);
- return Task.FromResult(0);
- }
- if (!_cameraPermissionsDenied && hasCameraPermission && hasStorageWritePermission)
- {
- try
- {
- var file = new Java.IO.File(activity.FilesDir, "temp_camera_photo.jpg");
- if (!file.Exists())
- {
- file.ParentFile.Mkdirs();
- file.CreateNewFile();
- }
- var outputFileUri = FileProvider.GetUriForFile(activity,
- "com.x8bit.bitwarden.fileprovider", file);
- additionalIntents.AddRange(GetCameraIntents(outputFileUri));
- }
- catch (Java.IO.IOException) { }
- }
- }
-
- var docIntent = new Intent(Intent.ActionOpenDocument);
- docIntent.AddCategory(Intent.CategoryOpenable);
- docIntent.SetType("*/*");
- var chooserIntent = Intent.CreateChooser(docIntent, AppResources.FileSource);
- if (additionalIntents.Count > 0)
- {
- chooserIntent.PutExtra(Intent.ExtraInitialIntents, additionalIntents.ToArray());
- }
- activity.StartActivityForResult(chooserIntent, Constants.SelectFileRequestCode);
- return Task.FromResult(0);
- }
-
public Task DisplayPromptAync(string title = null, string description = null,
string text = null, string okButtonText = null, string cancelButtonText = null,
bool numericKeyboard = false, bool autofocus = true, bool password = false)
@@ -467,34 +259,6 @@ namespace Bit.Droid.Services
}
}
- public void DisableAutofillService()
- {
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var type = Java.Lang.Class.FromType(typeof(AutofillManager));
- var manager = activity.GetSystemService(type) as AutofillManager;
- manager.DisableAutofillServices();
- }
- catch { }
- }
-
- public bool AutofillServicesEnabled()
- {
- if (Build.VERSION.SdkInt <= BuildVersionCodes.M)
- {
- // Android 5-6: Both accessibility & overlay are required or nothing happens
- return AutofillAccessibilityServiceRunning() && AutofillAccessibilityOverlayPermitted();
- }
- if (Build.VERSION.SdkInt == BuildVersionCodes.N)
- {
- // Android 7: Only accessibility is required (overlay is optional when using quick-action tile)
- return AutofillAccessibilityServiceRunning();
- }
- // Android 8+: Either autofill or accessibility is required
- return AutofillServiceEnabled() || AutofillAccessibilityServiceRunning();
- }
-
public string GetBuildNumber()
{
return Application.Context.ApplicationContext.PackageManager.GetPackageInfo(
@@ -526,25 +290,6 @@ namespace Bit.Droid.Services
return activity.PackageManager.HasSystemFeature(PackageManager.FeatureCamera);
}
- public bool SupportsAutofillService()
- {
- if (Build.VERSION.SdkInt < BuildVersionCodes.O)
- {
- return false;
- }
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var type = Java.Lang.Class.FromType(typeof(AutofillManager));
- var manager = activity.GetSystemService(type) as AutofillManager;
- return manager.IsAutofillSupported;
- }
- catch
- {
- return false;
- }
- }
-
public int SystemMajorVersion()
{
return (int)Build.VERSION.SdkInt;
@@ -635,112 +380,6 @@ namespace Bit.Droid.Services
title, cancel, destruction, buttons);
}
- public void Autofill(CipherView cipher)
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- if (activity == null)
- {
- return;
- }
- if (activity.Intent?.GetBooleanExtra("autofillFramework", false) ?? false)
- {
- if (cipher == null)
- {
- activity.SetResult(Result.Canceled);
- activity.Finish();
- return;
- }
- var structure = activity.Intent.GetParcelableExtra(
- AutofillManager.ExtraAssistStructure) as AssistStructure;
- if (structure == null)
- {
- activity.SetResult(Result.Canceled);
- activity.Finish();
- return;
- }
- var parser = new Parser(structure, activity.ApplicationContext);
- parser.Parse();
- if ((!parser.FieldCollection?.Fields?.Any() ?? true) || string.IsNullOrWhiteSpace(parser.Uri))
- {
- activity.SetResult(Result.Canceled);
- activity.Finish();
- return;
- }
- var task = CopyTotpAsync(cipher);
- var dataset = AutofillHelpers.BuildDataset(activity, parser.FieldCollection, new FilledItem(cipher));
- var replyIntent = new Intent();
- replyIntent.PutExtra(AutofillManager.ExtraAuthenticationResult, dataset);
- activity.SetResult(Result.Ok, replyIntent);
- activity.Finish();
- var eventTask = _eventServiceFunc().CollectAsync(EventType.Cipher_ClientAutofilled, cipher.Id);
- }
- else
- {
- var data = new Intent();
- if (cipher?.Login == null)
- {
- data.PutExtra("canceled", "true");
- }
- else
- {
- var task = CopyTotpAsync(cipher);
- data.PutExtra("uri", cipher.Login.Uri);
- data.PutExtra("username", cipher.Login.Username);
- data.PutExtra("password", cipher.Login.Password);
- }
- if (activity.Parent == null)
- {
- activity.SetResult(Result.Ok, data);
- }
- else
- {
- activity.Parent.SetResult(Result.Ok, data);
- }
- activity.Finish();
- _messagingService.Send("finishMainActivity");
- if (cipher != null)
- {
- var eventTask = _eventServiceFunc().CollectAsync(EventType.Cipher_ClientAutofilled, cipher.Id);
- }
- }
- }
-
- public void CloseAutofill()
- {
- Autofill(null);
- }
-
- public void Background()
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- if (activity.Intent?.GetBooleanExtra("autofillFramework", false) ?? false)
- {
- activity.SetResult(Result.Canceled);
- activity.Finish();
- }
- else
- {
- activity.MoveTaskToBack(true);
- }
- }
-
- public bool AutofillAccessibilityServiceRunning()
- {
- var enabledServices = Settings.Secure.GetString(Application.Context.ContentResolver,
- Settings.Secure.EnabledAccessibilityServices);
- return Application.Context.PackageName != null &&
- (enabledServices?.Contains(Application.Context.PackageName) ?? false);
- }
-
- public bool AutofillAccessibilityOverlayPermitted()
- {
- return Accessibility.AccessibilityHelpers.OverlayPermitted();
- }
-
- public bool HasAutofillService()
- {
- return true;
- }
public void OpenAccessibilityOverlayPermissionSettings()
{
@@ -771,25 +410,6 @@ namespace Bit.Droid.Services
}
}
- public bool AutofillServiceEnabled()
- {
- if (Build.VERSION.SdkInt < BuildVersionCodes.O)
- {
- return false;
- }
- try
- {
- var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
- var afm = (AutofillManager)activity.GetSystemService(
- Java.Lang.Class.FromType(typeof(AutofillManager)));
- return afm.IsEnabled && afm.HasEnabledAutofillServices;
- }
- catch
- {
- return false;
- }
- }
-
public void OpenAccessibilitySettings()
{
try
@@ -848,61 +468,6 @@ namespace Bit.Droid.Services
return true;
}
- private bool DeleteDir(Java.IO.File dir)
- {
- if (dir != null && dir.IsDirectory)
- {
- var children = dir.List();
- for (int i = 0; i < children.Length; i++)
- {
- var success = DeleteDir(new Java.IO.File(dir, children[i]));
- if (!success)
- {
- return false;
- }
- }
- return dir.Delete();
- }
- else if (dir != null && dir.IsFile)
- {
- return dir.Delete();
- }
- else
- {
- return false;
- }
- }
-
- private bool HasPermission(string permission)
- {
- return ContextCompat.CheckSelfPermission(
- CrossCurrentActivity.Current.Activity, permission) == Permission.Granted;
- }
-
- private void AskPermission(string permission)
- {
- ActivityCompat.RequestPermissions(CrossCurrentActivity.Current.Activity, new string[] { permission },
- Constants.SelectFilePermissionRequestCode);
- }
-
- private List GetCameraIntents(Android.Net.Uri outputUri)
- {
- var intents = new List();
- var pm = CrossCurrentActivity.Current.Activity.PackageManager;
- var captureIntent = new Intent(MediaStore.ActionImageCapture);
- var listCam = pm.QueryIntentActivities(captureIntent, 0);
- foreach (var res in listCam)
- {
- var packageName = res.ActivityInfo.PackageName;
- var intent = new Intent(captureIntent);
- intent.SetComponent(new ComponentName(packageName, res.ActivityInfo.Name));
- intent.SetPackage(packageName);
- intent.PutExtra(MediaStore.ExtraOutput, outputUri);
- intents.Add(intent);
- }
- return intents;
- }
-
private Intent RateIntentForUrl(string url, Activity activity)
{
var intent = new Intent(Intent.ActionView, Android.Net.Uri.Parse($"{url}?id={activity.PackageName}"));
@@ -920,24 +485,6 @@ namespace Bit.Droid.Services
return intent;
}
- private async Task CopyTotpAsync(CipherView cipher)
- {
- if (!string.IsNullOrWhiteSpace(cipher?.Login?.Totp))
- {
- var autoCopyDisabled = await _stateService.GetDisableAutoTotpCopyAsync();
- var canAccessPremium = await _stateService.CanAccessPremiumAsync();
- if ((canAccessPremium || cipher.OrganizationUseTotp) && !autoCopyDisabled.GetValueOrDefault())
- {
- var totpService = ServiceContainer.Resolve("totpService");
- var totp = await totpService.GetCodeAsync(cipher.Login.Totp);
- if (totp != null)
- {
- await _clipboardService.CopyTextAsync(totp);
- }
- }
- }
- }
-
public float GetSystemFontSizeScale()
{
var activity = CrossCurrentActivity.Current?.Activity as MainActivity;
@@ -964,5 +511,14 @@ namespace Bit.Droid.Services
}
activity.RunOnUiThread(() => activity.Window.AddFlags(WindowManagerFlags.Secure));
}
+
+ public void OpenAppSettings()
+ {
+ var intent = new Intent(Android.Provider.Settings.ActionApplicationDetailsSettings);
+ intent.AddFlags(ActivityFlags.NewTask);
+ var uri = Android.Net.Uri.FromParts("package", Application.Context.PackageName, null);
+ intent.SetData(uri);
+ Application.Context.StartActivity(intent);
+ }
}
}
diff --git a/src/Android/Services/FileService.cs b/src/Android/Services/FileService.cs
new file mode 100644
index 000000000..c217f7a51
--- /dev/null
+++ b/src/Android/Services/FileService.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Android;
+using Android.Content;
+using Android.Content.PM;
+using Android.OS;
+using Android.Provider;
+using Android.Webkit;
+using AndroidX.Core.App;
+using AndroidX.Core.Content;
+using Bit.App.Resources;
+using Bit.Core;
+using Bit.Core.Abstractions;
+using Plugin.CurrentActivity;
+
+namespace Bit.Droid.Services
+{
+ public class FileService : IFileService
+ {
+ private readonly IStateService _stateService;
+ private readonly IBroadcasterService _broadcasterService;
+
+ private bool _cameraPermissionsDenied;
+
+ public FileService(IStateService stateService, IBroadcasterService broadcasterService)
+ {
+ _stateService = stateService;
+ _broadcasterService = broadcasterService;
+
+ _broadcasterService.Subscribe(nameof(FileService), (message) =>
+ {
+ if (message.Command == "selectFileCameraPermissionDenied")
+ {
+ _cameraPermissionsDenied = true;
+ }
+ });
+ }
+
+ public bool OpenFile(byte[] fileData, string id, string fileName)
+ {
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var intent = BuildOpenFileIntent(fileData, fileName);
+ if (intent == null)
+ {
+ return false;
+ }
+ activity.StartActivity(intent);
+ return true;
+ }
+ catch { }
+ return false;
+ }
+
+ public bool CanOpenFile(string fileName)
+ {
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var intent = BuildOpenFileIntent(new byte[0], string.Concat("opentest_", fileName));
+ if (intent == null)
+ {
+ return false;
+ }
+ var activities = activity.PackageManager.QueryIntentActivities(intent,
+ PackageInfoFlags.MatchDefaultOnly);
+ return (activities?.Count ?? 0) > 0;
+ }
+ catch { }
+ return false;
+ }
+
+ private Intent BuildOpenFileIntent(byte[] fileData, string fileName)
+ {
+ var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower());
+ if (extension == null)
+ {
+ return null;
+ }
+ var mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension);
+ if (mimeType == null)
+ {
+ return null;
+ }
+
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var cachePath = activity.CacheDir;
+ var filePath = Path.Combine(cachePath.Path, fileName);
+ File.WriteAllBytes(filePath, fileData);
+ var file = new Java.IO.File(cachePath, fileName);
+ if (!file.IsFile)
+ {
+ return null;
+ }
+
+ try
+ {
+ var intent = new Intent(Intent.ActionView);
+ var uri = FileProvider.GetUriForFile(activity.ApplicationContext,
+ "com.x8bit.bitwarden.fileprovider", file);
+ intent.SetDataAndType(uri, mimeType);
+ intent.SetFlags(ActivityFlags.GrantReadUriPermission);
+ return intent;
+ }
+ catch { }
+ return null;
+ }
+
+ public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri)
+ {
+ try
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+
+ if (contentUri != null)
+ {
+ var uri = Android.Net.Uri.Parse(contentUri);
+ var stream = activity.ContentResolver.OpenOutputStream(uri);
+ // Using java bufferedOutputStream due to this issue:
+ // https://github.com/xamarin/xamarin-android/issues/3498
+ var javaStream = new Java.IO.BufferedOutputStream(stream);
+ javaStream.Write(fileData);
+ javaStream.Flush();
+ javaStream.Close();
+ return true;
+ }
+
+ // Prompt for location to save file
+ var extension = MimeTypeMap.GetFileExtensionFromUrl(fileName.Replace(' ', '_').ToLower());
+ if (extension == null)
+ {
+ return false;
+ }
+
+ string mimeType = MimeTypeMap.Singleton.GetMimeTypeFromExtension(extension);
+ if (mimeType == null)
+ {
+ // Unable to identify so fall back to generic "any" type
+ mimeType = "*/*";
+ }
+
+ var intent = new Intent(Intent.ActionCreateDocument);
+ intent.SetType(mimeType);
+ intent.AddCategory(Intent.CategoryOpenable);
+ intent.PutExtra(Intent.ExtraTitle, fileName);
+
+ activity.StartActivityForResult(intent, Core.Constants.SaveFileRequestCode);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(">>> {0}: {1}", ex.GetType(), ex.StackTrace);
+ }
+ return false;
+ }
+
+ public async Task ClearCacheAsync()
+ {
+ try
+ {
+ DeleteDir(CrossCurrentActivity.Current.Activity.CacheDir);
+ await _stateService.SetLastFileCacheClearAsync(DateTime.UtcNow);
+ }
+ catch (Exception) { }
+ }
+
+ public Task SelectFileAsync()
+ {
+ var activity = (MainActivity)CrossCurrentActivity.Current.Activity;
+ var hasStorageWritePermission = !_cameraPermissionsDenied &&
+ HasPermission(Manifest.Permission.WriteExternalStorage);
+ var additionalIntents = new List();
+ if (activity.PackageManager.HasSystemFeature(PackageManager.FeatureCamera))
+ {
+ var hasCameraPermission = !_cameraPermissionsDenied && HasPermission(Manifest.Permission.Camera);
+ if (!_cameraPermissionsDenied && !hasStorageWritePermission)
+ {
+ AskPermission(Manifest.Permission.WriteExternalStorage);
+ return Task.FromResult(0);
+ }
+ if (!_cameraPermissionsDenied && !hasCameraPermission)
+ {
+ AskPermission(Manifest.Permission.Camera);
+ return Task.FromResult(0);
+ }
+ if (!_cameraPermissionsDenied && hasCameraPermission && hasStorageWritePermission)
+ {
+ try
+ {
+ var file = new Java.IO.File(activity.FilesDir, "temp_camera_photo.jpg");
+ if (!file.Exists())
+ {
+ file.ParentFile.Mkdirs();
+ file.CreateNewFile();
+ }
+ var outputFileUri = FileProvider.GetUriForFile(activity,
+ "com.x8bit.bitwarden.fileprovider", file);
+ additionalIntents.AddRange(GetCameraIntents(outputFileUri));
+ }
+ catch (Java.IO.IOException) { }
+ }
+ }
+
+ var docIntent = new Intent(Intent.ActionOpenDocument);
+ docIntent.AddCategory(Intent.CategoryOpenable);
+ docIntent.SetType("*/*");
+ var chooserIntent = Intent.CreateChooser(docIntent, AppResources.FileSource);
+ if (additionalIntents.Count > 0)
+ {
+ chooserIntent.PutExtra(Intent.ExtraInitialIntents, additionalIntents.ToArray());
+ }
+ activity.StartActivityForResult(chooserIntent, Core.Constants.SelectFileRequestCode);
+ return Task.FromResult(0);
+ }
+
+ private bool DeleteDir(Java.IO.File dir)
+ {
+ if (dir is null)
+ {
+ return false;
+ }
+
+ if (dir.IsDirectory)
+ {
+ var children = dir.List();
+ for (int i = 0; i < children.Length; i++)
+ {
+ var success = DeleteDir(new Java.IO.File(dir, children[i]));
+ if (!success)
+ {
+ return false;
+ }
+ }
+ return dir.Delete();
+ }
+
+ if (dir.IsFile)
+ {
+ return dir.Delete();
+ }
+
+ return false;
+ }
+
+ private bool HasPermission(string permission)
+ {
+ return ContextCompat.CheckSelfPermission(
+ CrossCurrentActivity.Current.Activity, permission) == Permission.Granted;
+ }
+
+ private void AskPermission(string permission)
+ {
+ ActivityCompat.RequestPermissions(CrossCurrentActivity.Current.Activity, new string[] { permission },
+ Core.Constants.SelectFilePermissionRequestCode);
+ }
+
+ private List GetCameraIntents(Android.Net.Uri outputUri)
+ {
+ var intents = new List();
+ var pm = CrossCurrentActivity.Current.Activity.PackageManager;
+ var captureIntent = new Intent(MediaStore.ActionImageCapture);
+ var listCam = pm.QueryIntentActivities(captureIntent, 0);
+ foreach (var res in listCam)
+ {
+ var packageName = res.ActivityInfo.PackageName;
+ var intent = new Intent(captureIntent);
+ intent.SetComponent(new ComponentName(packageName, res.ActivityInfo.Name));
+ intent.SetPackage(packageName);
+ intent.PutExtra(MediaStore.ExtraOutput, outputUri);
+ intents.Add(intent);
+ }
+ return intents;
+ }
+ }
+}
diff --git a/src/App/Abstractions/IDeviceActionService.cs b/src/App/Abstractions/IDeviceActionService.cs
index 1c7f75941..8f4a19a34 100644
--- a/src/App/Abstractions/IDeviceActionService.cs
+++ b/src/App/Abstractions/IDeviceActionService.cs
@@ -1,6 +1,5 @@
using System.Threading.Tasks;
using Bit.Core.Enums;
-using Bit.Core.Models.View;
namespace Bit.App.Abstractions
{
@@ -8,46 +7,35 @@ namespace Bit.App.Abstractions
{
string DeviceUserAgent { get; }
DeviceType DeviceType { get; }
+ int SystemMajorVersion();
+ string SystemModel();
+ string GetBuildNumber();
+
void Toast(string text, bool longDuration = false);
- bool LaunchApp(string appName);
Task ShowLoadingAsync(string text);
Task HideLoadingAsync();
- bool OpenFile(byte[] fileData, string id, string fileName);
- bool SaveFile(byte[] fileData, string id, string fileName, string contentUri);
- bool CanOpenFile(string fileName);
- Task ClearCacheAsync();
- Task SelectFileAsync();
Task DisplayPromptAync(string title = null, string description = null, string text = null,
string okButtonText = null, string cancelButtonText = null, bool numericKeyboard = false,
bool autofocus = true, bool password = false);
- void RateApp();
+ Task DisplayAlertAsync(string title, string message, string cancel, params string[] buttons);
+ Task DisplayActionSheetAsync(string title, string cancel, string destruction, params string[] buttons);
+
bool SupportsFaceBiometric();
Task SupportsFaceBiometricAsync();
bool SupportsNfc();
bool SupportsCamera();
- bool SupportsAutofillService();
- int SystemMajorVersion();
- string SystemModel();
- Task DisplayAlertAsync(string title, string message, string cancel, params string[] buttons);
- Task DisplayActionSheetAsync(string title, string cancel, string destruction, params string[] buttons);
- void Autofill(CipherView cipher);
- void CloseAutofill();
- void Background();
- bool AutofillAccessibilityServiceRunning();
- bool AutofillAccessibilityOverlayPermitted();
- bool HasAutofillService();
- bool AutofillServiceEnabled();
- void DisableAutofillService();
- bool AutofillServicesEnabled();
- string GetBuildNumber();
+ bool SupportsFido2();
+
+ bool LaunchApp(string appName);
+ void RateApp();
void OpenAccessibilitySettings();
void OpenAccessibilityOverlayPermissionSettings();
void OpenAutofillSettings();
long GetActiveTime();
void CloseMainApp();
- bool SupportsFido2();
float GetSystemFontSizeScale();
Task OnAccountSwitchCompleteAsync();
Task SetScreenCaptureAllowedAsync();
+ void OpenAppSettings();
}
}
diff --git a/src/App/Abstractions/IPushNotificationListenerService.cs b/src/App/Abstractions/IPushNotificationListenerService.cs
index 4a57c75a5..fdbb6ca88 100644
--- a/src/App/Abstractions/IPushNotificationListenerService.cs
+++ b/src/App/Abstractions/IPushNotificationListenerService.cs
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
+using Bit.App.Models;
using Newtonsoft.Json.Linq;
namespace Bit.App.Abstractions
@@ -9,6 +10,8 @@ namespace Bit.App.Abstractions
Task OnRegisteredAsync(string token, string device);
void OnUnregistered(string device);
void OnError(string message, string device);
+ Task OnNotificationTapped(BaseNotificationData data);
+ Task OnNotificationDismissed(BaseNotificationData data);
bool ShouldShowNotification();
}
}
diff --git a/src/App/Abstractions/IPushNotificationService.cs b/src/App/Abstractions/IPushNotificationService.cs
index c4e3827cb..5d69be1aa 100644
--- a/src/App/Abstractions/IPushNotificationService.cs
+++ b/src/App/Abstractions/IPushNotificationService.cs
@@ -1,12 +1,17 @@
-using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Bit.App.Models;
namespace Bit.App.Abstractions
{
public interface IPushNotificationService
{
bool IsRegisteredForPush { get; }
+ Task AreNotificationsSettingsEnabledAsync();
Task GetTokenAsync();
Task RegisterAsync();
Task UnregisterAsync();
+ void SendLocalNotification(string title, string message, BaseNotificationData data);
+ void DismissLocalNotification(string notificationId);
}
}
diff --git a/src/App/App.csproj b/src/App/App.csproj
index 45398c599..200880c2d 100644
--- a/src/App/App.csproj
+++ b/src/App/App.csproj
@@ -21,7 +21,6 @@
-
@@ -123,6 +122,9 @@
Code
+
+ LoginPasswordlessPage.xaml
+
diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs
index 6870f20f2..e53a37902 100644
--- a/src/App/App.xaml.cs
+++ b/src/App/App.xaml.cs
@@ -7,9 +7,11 @@ using Bit.App.Resources;
using Bit.App.Services;
using Bit.App.Utilities;
using Bit.App.Utilities.AccountManagement;
+using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
+using Bit.Core.Models.Response;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Xamarin.Forms;
@@ -25,13 +27,14 @@ namespace Bit.App
private readonly IStateService _stateService;
private readonly IVaultTimeoutService _vaultTimeoutService;
private readonly ISyncService _syncService;
- private readonly IPlatformUtilsService _platformUtilsService;
private readonly IAuthService _authService;
- private readonly IStorageService _secureStorageService;
private readonly IDeviceActionService _deviceActionService;
+ private readonly IFileService _fileService;
private readonly IAccountsManager _accountsManager;
-
+ private readonly IPushNotificationService _pushNotificationService;
private static bool _isResumed;
+ // this variable is static because the app is launching new activities on notification click, creating new instances of App.
+ private static bool _pendingCheckPasswordlessLoginRequests;
public App(AppOptions appOptions)
{
@@ -47,10 +50,10 @@ namespace Bit.App
_vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService");
_syncService = ServiceContainer.Resolve("syncService");
_authService = ServiceContainer.Resolve("authService");
- _platformUtilsService = ServiceContainer.Resolve("platformUtilsService");
- _secureStorageService = ServiceContainer.Resolve("secureStorageService");
_deviceActionService = ServiceContainer.Resolve("deviceActionService");
+ _fileService = ServiceContainer.Resolve();
_accountsManager = ServiceContainer.Resolve("accountsManager");
+ _pushNotificationService = ServiceContainer.Resolve();
_accountsManager.Init(() => Options, this);
@@ -140,6 +143,10 @@ namespace Bit.App
new NavigationPage(new RemoveMasterPasswordPage()));
});
}
+ else if (message.Command == "passwordlessLoginRequest" || message.Command == "unlocked" || message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED)
+ {
+ CheckPasswordlessLoginRequestsAsync().FireAndForget();
+ }
}
catch (Exception ex)
{
@@ -148,11 +155,80 @@ namespace Bit.App
});
}
+ private async Task CheckPasswordlessLoginRequestsAsync()
+ {
+ if (!_isResumed)
+ {
+ _pendingCheckPasswordlessLoginRequests = true;
+ return;
+ }
+
+ _pendingCheckPasswordlessLoginRequests = false;
+ if (await _vaultTimeoutService.IsLockedAsync())
+ {
+ return;
+ }
+
+ var notification = await _stateService.GetPasswordlessLoginNotificationAsync();
+ if (notification == null)
+ {
+ return;
+ }
+
+ if (await CheckShouldSwitchActiveUserAsync(notification))
+ {
+ return;
+ }
+
+ // Delay to wait for the vault page to appear
+ await Task.Delay(2000);
+ var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(notification.Id);
+ var page = new LoginPasswordlessPage(new LoginPasswordlessDetails()
+ {
+ PubKey = loginRequestData.PublicKey,
+ Id = loginRequestData.Id,
+ IpAddress = loginRequestData.RequestIpAddress,
+ Email = await _stateService.GetEmailAsync(),
+ FingerprintPhrase = loginRequestData.RequestFingerprint,
+ RequestDate = loginRequestData.CreationDate,
+ DeviceType = loginRequestData.RequestDeviceType,
+ Origin = loginRequestData.Origin,
+ });
+ await _stateService.SetPasswordlessLoginNotificationAsync(null);
+ _pushNotificationService.DismissLocalNotification(Constants.PasswordlessNotificationId);
+ if (loginRequestData.CreationDate.ToUniversalTime().AddMinutes(Constants.PasswordlessNotificationTimeoutInMinutes) > DateTime.UtcNow)
+ {
+ await Device.InvokeOnMainThreadAsync(() => Application.Current.MainPage.Navigation.PushModalAsync(new NavigationPage(page)));
+ }
+ }
+
+ private async Task CheckShouldSwitchActiveUserAsync(PasswordlessRequestNotification notification)
+ {
+ var activeUserId = await _stateService.GetActiveUserIdAsync();
+ if (notification.UserId == activeUserId)
+ {
+ return false;
+ }
+
+ var notificationUserEmail = await _stateService.GetEmailAsync(notification.UserId);
+ await Device.InvokeOnMainThreadAsync(async () =>
+ {
+ var result = await _deviceActionService.DisplayAlertAsync(AppResources.LogInRequested, string.Format(AppResources.LoginAttemptFromXDoYouWantToSwitchToThisAccount, notificationUserEmail), AppResources.Cancel, AppResources.Ok);
+ if (result == AppResources.Ok)
+ {
+ await _stateService.SetActiveUserAsync(notification.UserId);
+ _messagingService.Send(AccountsManagerMessageCommands.SWITCHED_ACCOUNT);
+ }
+ });
+ return true;
+ }
+
public AppOptions Options { get; private set; }
protected async override void OnStart()
{
System.Diagnostics.Debug.WriteLine("XF App: OnStart");
+ _isResumed = true;
await ClearCacheIfNeededAsync();
Prime();
if (string.IsNullOrWhiteSpace(Options.Uri))
@@ -164,6 +240,10 @@ namespace Bit.App
SyncIfNeeded();
}
}
+ if (_pendingCheckPasswordlessLoginRequests)
+ {
+ CheckPasswordlessLoginRequestsAsync().FireAndForget();
+ }
if (Device.RuntimePlatform == Device.Android)
{
await _vaultTimeoutService.CheckVaultTimeoutAsync();
@@ -196,6 +276,10 @@ namespace Bit.App
{
System.Diagnostics.Debug.WriteLine("XF App: OnResume");
_isResumed = true;
+ if (_pendingCheckPasswordlessLoginRequests)
+ {
+ CheckPasswordlessLoginRequestsAsync().FireAndForget();
+ }
if (Device.RuntimePlatform == Device.Android)
{
ResumedAsync().FireAndForget();
@@ -245,7 +329,7 @@ namespace Bit.App
var lastClear = await _stateService.GetLastFileCacheClearAsync();
if ((DateTime.UtcNow - lastClear.GetValueOrDefault(DateTime.MinValue)).TotalDays >= 1)
{
- var task = Task.Run(() => _deviceActionService.ClearCacheAsync());
+ var task = Task.Run(() => _fileService.ClearCacheAsync());
}
}
diff --git a/src/App/Models/NotificationData.cs b/src/App/Models/NotificationData.cs
new file mode 100644
index 000000000..3ddd758d9
--- /dev/null
+++ b/src/App/Models/NotificationData.cs
@@ -0,0 +1,23 @@
+using System;
+namespace Bit.App.Models
+{
+ public abstract class BaseNotificationData
+ {
+ public abstract string Type { get; }
+
+ public string Id { get; set; }
+ }
+
+ public class PasswordlessNotificationData : BaseNotificationData
+ {
+ public const string TYPE = "passwordlessNotificationData";
+
+ public override string Type => TYPE;
+
+ public int TimeoutInMinutes { get; set; }
+
+ public string UserEmail { get; set; }
+ }
+
+}
+
diff --git a/src/App/Pages/Accounts/LoginPasswordlessPage.xaml b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml
new file mode 100644
index 000000000..83361c29c
--- /dev/null
+++ b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App/Pages/Accounts/LoginPasswordlessPage.xaml.cs b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml.cs
new file mode 100644
index 000000000..f6941a715
--- /dev/null
+++ b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml.cs
@@ -0,0 +1,43 @@
+using Xamarin.Forms;
+
+namespace Bit.App.Pages
+{
+ public partial class LoginPasswordlessPage : BaseContentPage
+ {
+ private LoginPasswordlessViewModel _vm;
+
+ public LoginPasswordlessPage(LoginPasswordlessDetails loginPasswordlessDetails)
+ {
+ InitializeComponent();
+ _vm = BindingContext as LoginPasswordlessViewModel;
+ _vm.Page = this;
+
+ _vm.LoginRequest = loginPasswordlessDetails;
+
+ if (Device.RuntimePlatform == Device.iOS)
+ {
+ ToolbarItems.Add(_closeItem);
+ }
+ }
+
+ private async void Close_Clicked(object sender, System.EventArgs e)
+ {
+ if (DoOnce())
+ {
+ await Navigation.PopModalAsync();
+ }
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ _vm.StartRequestTimeUpdater();
+ }
+
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+ _vm.StopRequestTimeUpdater();
+ }
+ }
+}
diff --git a/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs b/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs
new file mode 100644
index 000000000..1ca09b540
--- /dev/null
+++ b/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs
@@ -0,0 +1,175 @@
+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.Resources;
+using Bit.App.Utilities;
+using Bit.Core;
+using Bit.Core.Abstractions;
+using Bit.Core.Enums;
+using Bit.Core.Services;
+using Bit.Core.Utilities;
+using Xamarin.CommunityToolkit.ObjectModel;
+using Xamarin.Forms;
+
+namespace Bit.App.Pages
+{
+ public class LoginPasswordlessViewModel : BaseViewModel
+ {
+ private IDeviceActionService _deviceActionService;
+ private IAuthService _authService;
+ private IPlatformUtilsService _platformUtilsService;
+ private ILogger _logger;
+ private LoginPasswordlessDetails _resquest;
+ private CancellationTokenSource _requestTimeCts;
+ private Task _requestTimeTask;
+
+ private const int REQUEST_TIME_UPDATE_PERIOD_IN_MINUTES = 5;
+
+ public LoginPasswordlessViewModel()
+ {
+ _deviceActionService = ServiceContainer.Resolve("deviceActionService");
+ _platformUtilsService = ServiceContainer.Resolve("platformUtilsService");
+ _authService = ServiceContainer.Resolve("authService");
+ _logger = ServiceContainer.Resolve("logger");
+
+ PageTitle = AppResources.LogInRequested;
+
+ AcceptRequestCommand = new AsyncCommand(() => PasswordlessLoginAsync(true),
+ onException: ex => HandleException(ex),
+ allowsMultipleExecutions: false);
+ RejectRequestCommand = new AsyncCommand(() => PasswordlessLoginAsync(false),
+ onException: ex => HandleException(ex),
+ allowsMultipleExecutions: false);
+ }
+
+ public ICommand AcceptRequestCommand { get; }
+
+ public ICommand RejectRequestCommand { get; }
+
+ public string LogInAttemptByLabel => LoginRequest != null ? string.Format(AppResources.LogInAttemptByXOnY, LoginRequest.Email, LoginRequest.Origin) : string.Empty;
+
+ public string TimeOfRequestText => CreateRequestDate(LoginRequest?.RequestDate);
+
+ public bool ShowIpAddress => !string.IsNullOrEmpty(LoginRequest?.IpAddress);
+
+ public LoginPasswordlessDetails LoginRequest
+ {
+ get => _resquest;
+ set
+ {
+ SetProperty(ref _resquest, value, additionalPropertyNames: new string[]
+ {
+ nameof(LogInAttemptByLabel),
+ nameof(TimeOfRequestText),
+ nameof(ShowIpAddress),
+ });
+ }
+ }
+
+ public void StopRequestTimeUpdater()
+ {
+ try
+ {
+ _requestTimeCts?.Cancel();
+ _requestTimeCts?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.Exception(ex);
+ }
+ }
+
+ public void StartRequestTimeUpdater()
+ {
+ try
+ {
+ _requestTimeCts?.Cancel();
+ _requestTimeCts = new CancellationTokenSource();
+ _requestTimeTask = new TimerTask(_logger, UpdateRequestTime, _requestTimeCts).RunPeriodic(TimeSpan.FromMinutes(REQUEST_TIME_UPDATE_PERIOD_IN_MINUTES));
+ }
+ catch (Exception ex)
+ {
+ _logger.Exception(ex);
+ }
+ }
+
+ private async Task UpdateRequestTime()
+ {
+ TriggerPropertyChanged(nameof(TimeOfRequestText));
+ if (DateTime.UtcNow > LoginRequest?.RequestDate.ToUniversalTime().AddMinutes(Constants.PasswordlessNotificationTimeoutInMinutes))
+ {
+ StopRequestTimeUpdater();
+ await _platformUtilsService.ShowDialogAsync(AppResources.LoginRequestHasAlreadyExpired);
+ await Page.Navigation.PopModalAsync();
+ }
+ }
+
+ private async Task PasswordlessLoginAsync(bool approveRequest)
+ {
+ if (LoginRequest.RequestDate.ToUniversalTime().AddMinutes(Constants.PasswordlessNotificationTimeoutInMinutes) <= DateTime.UtcNow)
+ {
+ await _platformUtilsService.ShowDialogAsync(AppResources.LoginRequestHasAlreadyExpired);
+ await Page.Navigation.PopModalAsync();
+ return;
+ }
+
+ await _deviceActionService.ShowLoadingAsync(AppResources.Loading);
+ await _authService.PasswordlessLoginAsync(LoginRequest.Id, LoginRequest.PubKey, approveRequest);
+ await _deviceActionService.HideLoadingAsync();
+ await Page.Navigation.PopModalAsync();
+ _platformUtilsService.ShowToast("info", null, approveRequest ? AppResources.LogInAccepted : AppResources.LogInDenied);
+
+ StopRequestTimeUpdater();
+ }
+
+ private string CreateRequestDate(DateTime? requestDate)
+ {
+ if (!requestDate.HasValue)
+ {
+ return string.Empty;
+ }
+
+ if (DateTime.UtcNow < requestDate.Value.ToUniversalTime().AddMinutes(REQUEST_TIME_UPDATE_PERIOD_IN_MINUTES))
+ {
+ return AppResources.JustNow;
+ }
+
+ return string.Format(AppResources.XMinutesAgo, DateTime.UtcNow.Minute - requestDate.Value.ToUniversalTime().Minute);
+ }
+
+ private void HandleException(Exception ex)
+ {
+ Xamarin.Essentials.MainThread.InvokeOnMainThreadAsync(async () =>
+ {
+ await _deviceActionService.HideLoadingAsync();
+ await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage);
+ }).FireAndForget();
+ _logger.Exception(ex);
+ }
+ }
+
+ public class LoginPasswordlessDetails
+ {
+ public string Id { get; set; }
+
+ public string Key { get; set; }
+
+ public string PubKey { get; set; }
+
+ public string Origin { get; set; }
+
+ public string Email { get; set; }
+
+ public string FingerprintPhrase { get; set; }
+
+ public DateTime RequestDate { get; set; }
+
+ public string DeviceType { get; set; }
+
+ public string IpAddress { get; set; }
+ }
+}
diff --git a/src/App/Pages/Generator/GeneratorPageViewModel.cs b/src/App/Pages/Generator/GeneratorPageViewModel.cs
index d23e278ed..6e51ef0d4 100644
--- a/src/App/Pages/Generator/GeneratorPageViewModel.cs
+++ b/src/App/Pages/Generator/GeneratorPageViewModel.cs
@@ -20,6 +20,7 @@ namespace Bit.App.Pages
private readonly IPlatformUtilsService _platformUtilsService;
private readonly IClipboardService _clipboardService;
private readonly IUsernameGenerationService _usernameGenerationService;
+ private readonly ITokenService _tokenService;
readonly LazyResolve _logger = new LazyResolve("logger");
private PasswordGenerationOptions _options;
@@ -49,8 +50,6 @@ namespace Bit.App.Pages
private bool _showFirefoxRelayApiAccessToken;
private bool _showAnonAddyApiAccessToken;
private bool _showSimpleLoginApiKey;
- private UsernameEmailType _catchAllEmailTypeSelected;
- private UsernameEmailType _plusAddressedEmailTypeSelected;
private bool _editMode;
public GeneratorPageViewModel()
@@ -59,6 +58,7 @@ namespace Bit.App.Pages
_platformUtilsService = ServiceContainer.Resolve();
_clipboardService = ServiceContainer.Resolve();
_usernameGenerationService = ServiceContainer.Resolve();
+ _tokenService = ServiceContainer.Resolve();
PageTitle = AppResources.Generator;
GeneratorTypeOptions = new List {
@@ -157,7 +157,7 @@ namespace Bit.App.Pages
public bool ShowUsernameEmailType
{
- get => !string.IsNullOrWhiteSpace(EmailWebsite) || EditMode;
+ get => !string.IsNullOrWhiteSpace(EmailWebsite);
}
public int Length
@@ -367,7 +367,7 @@ namespace Bit.App.Pages
IsUsername = value == GeneratorType.Username;
TriggerPropertyChanged(nameof(GeneratorTypeSelected));
SaveOptionsAsync().FireAndForget();
- SaveUsernameOptionsAsync(false).FireAndForget();
+ SaveUsernameOptionsAsync().FireAndForget();
}
}
}
@@ -382,6 +382,7 @@ namespace Bit.App.Pages
IsPassword = value == 0;
TriggerPropertyChanged(nameof(PasswordTypeSelectedIndex));
SaveOptionsAsync().FireAndForget();
+ SaveUsernameOptionsAsync().FireAndForget();
}
}
}
@@ -396,7 +397,7 @@ namespace Bit.App.Pages
_usernameOptions.Type = value;
Username = Constants.DefaultUsernameGenerated;
TriggerPropertyChanged(nameof(UsernameTypeSelected), new string[] { nameof(UsernameTypeDescriptionLabel) });
- SaveUsernameOptionsAsync(false).FireAndForget();
+ SaveUsernameOptionsAsync().FireAndForget();
}
}
}
@@ -564,26 +565,28 @@ namespace Bit.App.Pages
public UsernameEmailType PlusAddressedEmailTypeSelected
{
- get => _plusAddressedEmailTypeSelected;
+ get => _usernameOptions.PlusAddressedEmailType;
set
{
- if (SetProperty(ref _plusAddressedEmailTypeSelected, value))
+ if (_usernameOptions.PlusAddressedEmailType != value)
{
_usernameOptions.PlusAddressedEmailType = value;
- SaveUsernameOptionsAsync(false).FireAndForget();
+ TriggerPropertyChanged(nameof(PlusAddressedEmailTypeSelected));
+ SaveUsernameOptionsAsync().FireAndForget();
}
}
}
public UsernameEmailType CatchAllEmailTypeSelected
{
- get => _catchAllEmailTypeSelected;
+ get => _usernameOptions.CatchAllEmailType;
set
{
- if (SetProperty(ref _catchAllEmailTypeSelected, value))
+ if (_usernameOptions.CatchAllEmailType != value)
{
_usernameOptions.CatchAllEmailType = value;
- SaveUsernameOptionsAsync(false).FireAndForget();
+ TriggerPropertyChanged(nameof(CatchAllEmailTypeSelected));
+ SaveUsernameOptionsAsync().FireAndForget();
}
}
}
@@ -601,16 +604,28 @@ namespace Bit.App.Pages
{
(_options, EnforcedPolicyOptions) = await _passwordGenerationService.GetOptionsAsync();
LoadFromOptions();
- await RegenerateAsync();
_usernameOptions = await _usernameGenerationService.GetOptionsAsync();
+ _usernameOptions.PlusAddressedEmail = _tokenService.GetEmail();
+ _usernameOptions.EmailWebsite = EmailWebsite;
+ _usernameOptions.CatchAllEmailType = _usernameOptions.PlusAddressedEmailType = string.IsNullOrWhiteSpace(EmailWebsite) || !EditMode ? UsernameEmailType.Random : UsernameEmailType.Website;
- if (!EditMode)
+ if (!IsUsername)
{
- _usernameOptions.CatchAllEmailType = _usernameOptions.PlusAddressedEmailType = UsernameEmailType.Random;
+ await RegenerateAsync();
+ }
+ else
+ {
+ if (UsernameTypeSelected != UsernameType.ForwardedEmailAlias)
+ {
+ await RegenerateUsernameAsync();
+ }
+ else
+ {
+ Username = Constants.DefaultUsernameGenerated;
+ }
}
TriggerUsernamePropertiesChanged();
- Username = Constants.DefaultUsernameGenerated;
_doneIniting = true;
}
@@ -668,10 +683,14 @@ namespace Bit.App.Pages
await _usernameGenerationService.SaveOptionsAsync(_usernameOptions);
- if (regenerate)
+ if (regenerate && UsernameTypeSelected != UsernameType.ForwardedEmailAlias)
{
await RegenerateUsernameAsync();
}
+ else
+ {
+ Username = Constants.DefaultUsernameGenerated;
+ }
}
public async Task SliderChangedAsync()
diff --git a/src/App/Pages/Send/SendAddEditPageViewModel.cs b/src/App/Pages/Send/SendAddEditPageViewModel.cs
index 276b99a73..a0b55e2dc 100644
--- a/src/App/Pages/Send/SendAddEditPageViewModel.cs
+++ b/src/App/Pages/Send/SendAddEditPageViewModel.cs
@@ -19,6 +19,7 @@ namespace Bit.App.Pages
public class SendAddEditPageViewModel : BaseViewModel
{
private readonly IDeviceActionService _deviceActionService;
+ private readonly IFileService _fileService;
private readonly IPlatformUtilsService _platformUtilsService;
private readonly IMessagingService _messagingService;
private readonly IStateService _stateService;
@@ -51,6 +52,7 @@ namespace Bit.App.Pages
public SendAddEditPageViewModel()
{
_deviceActionService = ServiceContainer.Resolve("deviceActionService");
+ _fileService = ServiceContainer.Resolve();
_platformUtilsService = ServiceContainer.Resolve("platformUtilsService");
_messagingService = ServiceContainer.Resolve("messagingService");
_stateService = ServiceContainer.Resolve("stateService");
@@ -292,7 +294,7 @@ namespace Bit.App.Pages
public async Task ChooseFileAsync()
{
- await _deviceActionService.SelectFileAsync();
+ await _fileService.SelectFileAsync();
}
public void ClearExpirationDate()
diff --git a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs
index ada402713..6fd3c0f16 100644
--- a/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs
+++ b/src/App/Pages/Send/SendGroupingsPage/SendGroupingsPageViewModel.cs
@@ -144,7 +144,7 @@ namespace Bit.App.Pages
{
await LoadDataAsync();
- var uppercaseGroupNames = _deviceActionService.DeviceType == DeviceType.iOS;
+ var uppercaseGroupNames = Device.RuntimePlatform == Device.iOS;
if (MainPage)
{
groupedSends.Add(new SendGroupingsPageListGroup(
diff --git a/src/App/Pages/Settings/AutofillServicesPage.xaml b/src/App/Pages/Settings/AutofillServicesPage.xaml
index 15f5c9b2a..38ad244f7 100644
--- a/src/App/Pages/Settings/AutofillServicesPage.xaml
+++ b/src/App/Pages/Settings/AutofillServicesPage.xaml
@@ -83,7 +83,7 @@
StyleClass="box-value"
HorizontalOptions="End" />
diff --git a/src/iOS.Extension/Info.plist b/src/iOS.Extension/Info.plist
index 3c1f2116b..bce26b6bb 100644
--- a/src/iOS.Extension/Info.plist
+++ b/src/iOS.Extension/Info.plist
@@ -11,7 +11,7 @@
CFBundleIdentifier
com.8bit.bitwarden.find-login-action-extension
CFBundleShortVersionString
- 2022.9.2
+ 2022.10.1
CFBundleLocalizations
en
diff --git a/src/iOS.ShareExtension/Info.plist b/src/iOS.ShareExtension/Info.plist
index 8a7b1a03e..d0f6ce6ce 100644
--- a/src/iOS.ShareExtension/Info.plist
+++ b/src/iOS.ShareExtension/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2022.9.2
+ 2022.10.1
CFBundleVersion
1
MinimumOSVersion
diff --git a/src/iOS.ShareExtension/iOS.ShareExtension.csproj b/src/iOS.ShareExtension/iOS.ShareExtension.csproj
index 5a7757f65..d86866cc3 100644
--- a/src/iOS.ShareExtension/iOS.ShareExtension.csproj
+++ b/src/iOS.ShareExtension/iOS.ShareExtension.csproj
@@ -166,7 +166,6 @@
-
4.5.3
diff --git a/src/iOS/AppDelegate.cs b/src/iOS/AppDelegate.cs
index 9686c8071..25b9049e2 100644
--- a/src/iOS/AppDelegate.cs
+++ b/src/iOS/AppDelegate.cs
@@ -16,6 +16,7 @@ using Bit.iOS.Services;
using CoreNFC;
using Foundation;
using UIKit;
+using UserNotifications;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
@@ -56,7 +57,6 @@ namespace Bit.iOS
LoadApplication(new App.App(null));
iOSCoreHelpers.AppearanceAdjustments();
ZXing.Net.Mobile.Forms.iOS.Platform.Init();
-
_broadcasterService.Subscribe(nameof(AppDelegate), async (message) =>
{
try
diff --git a/src/iOS/Info.plist b/src/iOS/Info.plist
index 359b11b15..f58f63696 100644
--- a/src/iOS/Info.plist
+++ b/src/iOS/Info.plist
@@ -11,7 +11,7 @@
CFBundleIdentifier
com.8bit.bitwarden
CFBundleShortVersionString
- 2022.9.2
+ 2022.10.1
CFBundleVersion
1
CFBundleIconName
diff --git a/src/iOS/Services/iOSPushNotificationHandler.cs b/src/iOS/Services/iOSPushNotificationHandler.cs
index 197a7fe1a..b19fe17e0 100644
--- a/src/iOS/Services/iOSPushNotificationHandler.cs
+++ b/src/iOS/Services/iOSPushNotificationHandler.cs
@@ -1,8 +1,13 @@
using System;
using System.Diagnostics;
using Bit.App.Abstractions;
+using Bit.App.Models;
+using Bit.Core;
+using Bit.Core.Enums;
using Bit.Core.Services;
+using CoreData;
using Foundation;
+using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UserNotifications;
using Xamarin.Forms;
@@ -83,7 +88,6 @@ namespace Bit.iOS.Services
public void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, Action completionHandler)
{
Debug.WriteLine($"{TAG} WillPresentNotification {notification?.Request?.Content?.UserInfo}");
-
OnMessageReceived(notification?.Request?.Content?.UserInfo);
completionHandler(UNNotificationPresentationOptions.Alert);
}
@@ -92,12 +96,35 @@ namespace Bit.iOS.Services
public void DidReceiveNotificationResponse(UNUserNotificationCenter center, UNNotificationResponse response, Action completionHandler)
{
Debug.WriteLine($"{TAG} DidReceiveNotificationResponse {response?.Notification?.Request?.Content?.UserInfo}");
-
- if (response.IsDefaultAction)
+ if ((response?.Notification?.Request?.Content?.UserInfo) == null)
{
- OnMessageReceived(response?.Notification?.Request?.Content?.UserInfo);
+ completionHandler();
+ return;
}
+ var userInfo = response?.Notification?.Request?.Content?.UserInfo;
+ OnMessageReceived(userInfo);
+
+ if (userInfo.TryGetValue(NSString.FromObject(Constants.NotificationData), out NSObject nsObject))
+ {
+ var token = JToken.Parse(NSString.FromObject(nsObject).ToString());
+ var typeToken = token.SelectToken(Constants.NotificationDataType);
+ if (response.IsDefaultAction)
+ {
+ if (typeToken.ToString() == PasswordlessNotificationData.TYPE)
+ {
+ _pushNotificationListenerService.OnNotificationTapped(token.ToObject());
+ }
+ }
+ else if (response.IsDismissAction)
+ {
+ if (typeToken.ToString() == PasswordlessNotificationData.TYPE)
+ {
+ _pushNotificationListenerService.OnNotificationDismissed(token.ToObject());
+ }
+ }
+ }
+
// Inform caller it has been handled
completionHandler();
}
diff --git a/src/iOS/Services/iOSPushNotificationService.cs b/src/iOS/Services/iOSPushNotificationService.cs
index c268734c1..aec24657b 100644
--- a/src/iOS/Services/iOSPushNotificationService.cs
+++ b/src/iOS/Services/iOSPushNotificationService.cs
@@ -1,7 +1,17 @@
-using System.Diagnostics;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
using System.Threading.Tasks;
using Bit.App.Abstractions;
+using Bit.App.Models;
+using Bit.App.Resources;
+using Bit.App.Services;
+using Bit.Core;
+using Bit.Core.Services;
using Foundation;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
using UIKit;
using UserNotifications;
@@ -19,6 +29,12 @@ namespace Bit.iOS.Services
public bool IsRegisteredForPush => UIApplication.SharedApplication.IsRegisteredForRemoteNotifications;
+ public async Task AreNotificationsSettingsEnabledAsync()
+ {
+ var settings = await UNUserNotificationCenter.Current.GetNotificationSettingsAsync();
+ return settings.AlertSetting == UNNotificationSetting.Enabled;
+ }
+
public async Task RegisterAsync()
{
Debug.WriteLine($"{TAG} RegisterAsync");
@@ -58,5 +74,49 @@ namespace Bit.iOS.Services
NSUserDefaults.StandardUserDefaults.Synchronize();
return Task.FromResult(0);
}
+
+ public void SendLocalNotification(string title, string message, BaseNotificationData data)
+ {
+ if (string.IsNullOrEmpty(data.Id))
+ {
+ throw new ArgumentNullException("notificationId cannot be null or empty.");
+ }
+
+ var content = new UNMutableNotificationContent()
+ {
+ Title = title,
+ Body = message,
+ CategoryIdentifier = Constants.iOSNotificationCategoryId
+ };
+
+ if (data != null)
+ {
+ content.UserInfo = NSDictionary.FromObjectAndKey(NSData.FromString(JsonConvert.SerializeObject(data), NSStringEncoding.UTF8), new NSString(Constants.NotificationData));
+ }
+
+ var actions = new UNNotificationAction[] { UNNotificationAction.FromIdentifier(Constants.iOSNotificationClearActionId, AppResources.Clear, UNNotificationActionOptions.Foreground) };
+ var category = UNNotificationCategory.FromIdentifier(Constants.iOSNotificationCategoryId, actions, new string[] { }, UNNotificationCategoryOptions.CustomDismissAction);
+ UNUserNotificationCenter.Current.SetNotificationCategories(new NSSet(category));
+
+ var request = UNNotificationRequest.FromIdentifier(data.Id, content, null);
+ UNUserNotificationCenter.Current.AddNotificationRequest(request, (err) =>
+ {
+ if (err != null)
+ {
+ Logger.Instance.Exception(new Exception($"Failed to schedule notification: {err}"));
+ }
+ });
+ }
+
+ public void DismissLocalNotification(string notificationId)
+ {
+ if (string.IsNullOrEmpty(notificationId))
+ {
+ return;
+ }
+
+ UNUserNotificationCenter.Current.RemovePendingNotificationRequests(new string[] { notificationId });
+ UNUserNotificationCenter.Current.RemoveDeliveredNotifications(new string[] { notificationId });
+ }
}
}
\ No newline at end of file
diff --git a/store/apple/es/copy.resx b/store/apple/es/copy.resx
index ea78d344b..717610955 100644
--- a/store/apple/es/copy.resx
+++ b/store/apple/es/copy.resx
@@ -122,31 +122,31 @@
Max 30 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ Bitwarden, Inc. es la empresa matriz de 8bit Solutions LLC.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+NOMBRADO COMO EL MEJOR GESTOR DE CONTRASEÑAS POR THE VERGE, U.S. NEWS & WORLD REPORT, CNET, ENTRE OTROS.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+Gestione, almacene, asegure y comparta contraseñas ilimitadas, a través de dispositivos ilimitados desde cualquier lugar.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Genere contraseñas fuertes, únicas y aleatorias, basadas en los requisitos de seguridad para cada página que frecuenta.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+Bitwarden Send transmite rápidamente información encriptada --- archivos y texto -- directamente a cualquier persona.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+Bitwarden ofrece los planes Teams y Enterprise para que compartas tus contraseñas de una manera segura con tus compañeros.
-Why Choose Bitwarden:
+Razones para elegir a Bitwarden:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+Encriptación de clase mundial
+Las contraseñas son protegidas con encriptación avanzada de extremo a extremo (AES-256 bit y PBKDF2 SHA-2) para que tus datos permanezcan seguros y privados.
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Generador de contraseñas integrado
+Genere contraseñas fuertes, únicas y aleatorias, basadas en los requisitos de seguridad para cada página que frecuenta.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+Traducciones globales
+Bitwarden se encuentra disponible en más de 40 idiomas, gracias a nuestra comunidad global.
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
+Aplicaciones multiplataforma
+Asegure y comparta información sensible dentro de su Bóveda de Bitwarden desde cualquier navegador, dispositivo móvil, o computadora, y más.
Max 4000 characters
diff --git a/store/apple/fa/copy.resx b/store/apple/fa/copy.resx
index 60a07c69a..ad3aebd01 100644
--- a/store/apple/fa/copy.resx
+++ b/store/apple/fa/copy.resx
@@ -122,32 +122,31 @@
Max 30 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ Bitwarden، Inc. شرکت مادر 8bit Solutions LLC است.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+به عنوان بهترین مدیر رمز عبور توسط VERGE، US News & WORLD REPORT، CNET و دیگران انتخاب شد.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+گذرواژههای با تعداد نامحدود را در دستگاههای نامحدود از هر کجا مدیریت کنید، ذخیره کنید، ایمن کنید و به اشتراک بگذارید. Bitwarden راه حل های مدیریت رمز عبور منبع باز را به همه ارائه می دهد، چه در خانه، چه در محل کار یا در حال حرکت.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+بر اساس الزامات امنیتی برای هر وب سایتی که بازدید می کنید، رمزهای عبور قوی، منحصر به فرد و تصادفی ایجاد کنید.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+Bitwarden Send به سرعت اطلاعات رمزگذاری شده --- فایل ها و متن ساده - را مستقیماً به هر کسی منتقل می کند.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+Bitwarden برنامههای Teams و Enterprise را برای شرکتها ارائه میدهد تا بتوانید بهطور ایمن گذرواژهها را با همکاران خود به اشتراک بگذارید.
-Why Choose Bitwarden:
+چرا Bitwarden را انتخاب کنید:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+رمزگذاری در کلاس جهانی
+گذرواژهها با رمزگذاری پیشرفته (AES-256 بیت، هشتگ سالت و PBKDF2 SHA-256) محافظت میشوند تا دادههای شما امن و خصوصی بمانند.
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+تولیدکننده رمز عبور داخلی
+بر اساس الزامات امنیتی برای هر وب سایتی که بازدید می کنید، رمزهای عبور قوی، منحصر به فرد و تصادفی ایجاد کنید.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+ترجمه های جهانی
+ترجمه Bitwarden به 40 زبان وجود دارد و به لطف جامعه جهانی ما در حال رشد است.
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
-
+برنامه های کاربردی چند پلتفرمی
+داده های حساس را در Bitwarden Vault خود از هر مرورگر، دستگاه تلفن همراه یا سیستم عامل دسکتاپ و غیره ایمن کنید و به اشتراک بگذارید.
Max 4000 characters
diff --git a/store/apple/hu/copy.resx b/store/apple/hu/copy.resx
index 43fc25271..99e622e12 100644
--- a/store/apple/hu/copy.resx
+++ b/store/apple/hu/copy.resx
@@ -122,32 +122,35 @@
Max 30 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ A Bitwarden, Inc. a 8bit Solutions LLC anyavállalata.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+A LEGJOBB JELSZÓKEZELŐ NEVE, A SZEMÉLY, U.S. HÍREK ÉS VILÁGJELENTÉS, CNET ÉS TÖBBEK.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+Korlátlan számú jelszavak kezelése, tárolása, biztonsága és megosztása korlátlan számú eszközön, bárhonnan. A Bitwarden nyílt forráskódú jelszókezelési megoldásokat kínál mindenki számára, legyen szó otthonról, munkahelyről, vagy útközben.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Erős, egyedi és véletlenszerű jelszavak generálása a biztonsági követelmények alapján minden látogatott webhelyhez.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+A Bitwarden Send gyorsan továbbítja a titkosított információkat -- fájlokat és egyszerű szöveget -- közvetlenül bárkinek.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+A Bitwarden Teams és Enterprise csomagokat kínál a vállalatok számára, így biztonságosan megoszthatja jelszavait kollégáival.
-Why Choose Bitwarden:
+Miért válassza a Bitwarden-t:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+Világszínvonalú titkosítás
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+A jelszavakat fejlett végpontok közötti titkosítás védi (AES-256 bit, sózott hashtag és PBKDF2 SHA-256), így az adatok biztonságban és privátak maradnak.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+Beépített jelszógenerátor
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
-
+Erős, egyedi és véletlenszerű jelszavakat generál a biztonsági követelmények alapján minden látogatott webhelyhez.
+
+Globális fordítások
+
+A Bitwarden fordítások 40 nyelven léteznek, és a globális közösségünknek köszönhetően növekszik.
+
+Platformokon átívelő alkalmazások
+
+Biztonságos adatok védelme és megosztása a Bitwarden széfen belül bármely böngészőből, mobileszközről vagy asztali operációs rendszerről, stb.
Max 4000 characters
diff --git a/store/google/be/copy.resx b/store/google/be/copy.resx
index b8a9f32ac..f9d78099c 100644
--- a/store/google/be/copy.resx
+++ b/store/google/be/copy.resx
@@ -175,6 +175,6 @@ Bitwarden перакладзены на 40 моў свету (прысутніч
- тэлефон
- планшэт
- камп'ютар
-- Web-браузер
+- браўзер
diff --git a/store/google/es/copy.resx b/store/google/es/copy.resx
index 0df575bc8..aed719c2a 100644
--- a/store/google/es/copy.resx
+++ b/store/google/es/copy.resx
@@ -126,31 +126,32 @@
Max 80 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ Bitwarden, Inc. es la empresa matriz de 8bit Solutions LLC.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+NOMBRADO COMO EL MEJOR GESTOR DE CONTRASEÑAS POR THE VERGE, U.S. NEWS & WORLD REPORT, CNET, ENTRE OTROS.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+Administre, almacene, asegure, y comparta contraseñas ilimitadas a través de dispositivos ilimitados desde cualquier lugar.
+Bitwarden soluciones de código abierto para la gestión de contraseñas a todas las personas, sin importar si se encuentran en casa, en el trabajo, o en el camino.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Genere contraseñas fuertes, únicas y aleatorias, basadas en los requisitos de seguridad para cada página que frecuenta.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+Bitwarden Send transmite rápidamente información encriptada -- archivos y texto -- directamente a cualquier persona.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+Bitwarden ofrece los planes Teams y Enterprise para que comparta de manera segura las contraseñas con sus compañeros.
-Why Choose Bitwarden:
+Razones para elegir a Bitwarden:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+Encriptación de clase mundial
+Las contraseñas se encuentran protegidas con encriptación de extremo a extremo (AES-256 bit, y PBKDF2 SHA-256) para que sus datos permanezcan seguros y privados.
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Generador de contraseñas integrado
+Genere contraseñas fuertes, únicas y aleatorias, basadas en los requisitos de seguridad para cada página que frecuenta.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+Traducciones globales
+Bitwarden se encuentra disponible en más de 40 idiomas, gracias a nuestra comunidad global.
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
+Aplicaciones multiplataforma
+Asegure y comparta información sensible dentro de su Bóveda de Bitwarden desde cualquier navegador, dispositivo móvil, computadora, entre otros.
Max 4000 characters
diff --git a/store/google/fa/copy.resx b/store/google/fa/copy.resx
index 7d3f5978f..0d06552b3 100644
--- a/store/google/fa/copy.resx
+++ b/store/google/fa/copy.resx
@@ -126,32 +126,31 @@
Max 80 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ Bitwarden، Inc. شرکت مادر 8bit Solutions LLC است.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+به عنوان بهترین مدیر رمز عبور توسط VERGE، US News & WORLD REPORT، CNET و دیگران انتخاب شد.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+گذرواژههای با تعداد نامحدود را در دستگاههای نامحدود از هر کجا مدیریت کنید، ذخیره کنید، ایمن کنید و به اشتراک بگذارید. Bitwarden راه حل های مدیریت رمز عبور منبع باز را به همه ارائه می دهد، چه در خانه، چه در محل کار یا در حال حرکت.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+بر اساس الزامات امنیتی برای هر وب سایتی که بازدید می کنید، رمزهای عبور قوی، منحصر به فرد و تصادفی ایجاد کنید.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+Bitwarden Send به سرعت اطلاعات رمزگذاری شده --- فایل ها و متن ساده - را مستقیماً به هر کسی منتقل می کند.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+Bitwarden برنامههای Teams و Enterprise را برای شرکتها ارائه میدهد تا بتوانید بهطور ایمن گذرواژهها را با همکاران خود به اشتراک بگذارید.
-Why Choose Bitwarden:
+چرا Bitwarden را انتخاب کنید:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+رمزگذاری در کلاس جهانی
+گذرواژهها با رمزگذاری پیشرفته (AES-256 بیت، هشتگ سالت و PBKDF2 SHA-256) محافظت میشوند تا دادههای شما امن و خصوصی بمانند.
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+تولیدکننده رمز عبور داخلی
+بر اساس الزامات امنیتی برای هر وب سایتی که بازدید می کنید، رمزهای عبور قوی، منحصر به فرد و تصادفی ایجاد کنید.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+ترجمه های جهانی
+ترجمه Bitwarden به 40 زبان وجود دارد و به لطف جامعه جهانی ما در حال رشد است.
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
-
+برنامه های کاربردی چند پلتفرمی
+داده های حساس را در Bitwarden Vault خود از هر مرورگر، دستگاه تلفن همراه یا سیستم عامل دسکتاپ و غیره ایمن کنید و به اشتراک بگذارید.
Max 4000 characters
diff --git a/store/google/hu/copy.resx b/store/google/hu/copy.resx
index 412754629..0683c3476 100644
--- a/store/google/hu/copy.resx
+++ b/store/google/hu/copy.resx
@@ -126,31 +126,35 @@
Max 80 characters
- Bitwarden, Inc. is the parent company of 8bit Solutions LLC.
+ A Bitwarden, Inc. a 8bit Solutions LLC anyavállalata.
-NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE.
+A LEGJOBB JELSZÓKEZELŐ NEVE, A SZEMÉLY, U.S. HÍREK ÉS VILÁGJELENTÉS, CNET ÉS TÖBBEK.
-Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go.
+Korlátlan számú jelszavak kezelése, tárolása, biztonsága és megosztása korlátlan számú eszközön, bárhonnan. A Bitwarden nyílt forráskódú jelszókezelési megoldásokat kínál mindenki számára, legyen szó otthonról, munkahelyről, vagy útközben.
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+Erős, egyedi és véletlenszerű jelszavak generálása a biztonsági követelmények alapján minden látogatott webhelyhez.
-Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone.
+A Bitwarden Send gyorsan továbbítja a titkosított információkat -- fájlokat és egyszerű szöveget -- közvetlenül bárkinek.
-Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues.
+A Bitwarden Teams és Enterprise csomagokat kínál a vállalatok számára, így biztonságosan megoszthatja jelszavait kollégáival.
-Why Choose Bitwarden:
+Miért válassza a Bitwarden-t:
-World-Class Encryption
-Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashtag, and PBKDF2 SHA-256) so your data stays secure and private.
+Világszínvonalú titkosítás
-Built-in Password Generator
-Generate strong, unique, and random passwords based on security requirements for every website you frequent.
+A jelszavakat fejlett végpontok közötti titkosítás védi (AES-256 bit, sózott hashtag és PBKDF2 SHA-256), így az adatok biztonságban és privátak maradnak.
-Global Translations
-Bitwarden translations exist in 40 languages and are growing, thanks to our global community.
+Beépített jelszógenerátor
-Cross-Platform Applications
-Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
+Erős, egyedi és véletlenszerű jelszavakat generál a biztonsági követelmények alapján minden látogatott webhelyhez.
+
+Globális fordítások
+
+A Bitwarden fordítások 40 nyelven léteznek, és a globális közösségünknek köszönhetően növekszik.
+
+Platformokon átívelő alkalmazások
+
+Biztonságos adatok védelme és megosztása a Bitwarden széfen belül bármely böngészőből, mobileszközről vagy asztali operációs rendszerről, stb.
Max 4000 characters