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