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; using Android.OS; 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; 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) { _clipboardService = clipboardService; _stateService = stateService; _messagingService = messagingService; _broadcasterService = broadcasterService; _eventServiceFunc = eventServiceFunc; _broadcasterService.Subscribe(nameof(DeviceActionService), (message) => { if (message.Command == "selectFileCameraPermissionDenied") { _cameraPermissionsDenied = true; } }); } public string DeviceUserAgent { get { if (string.IsNullOrWhiteSpace(_userAgent)) { _userAgent = $"Bitwarden_Mobile/{Xamarin.Essentials.AppInfo.VersionString} " + $"(Android {Build.VERSION.Release}; SDK {Build.VERSION.Sdk}; Model {Build.Model})"; } return _userAgent; } } public DeviceType DeviceType => DeviceType.Android; public void Toast(string text, bool longDuration = false) { if (_toast != null) { _toast.Cancel(); _toast.Dispose(); _toast = null; } _toast = Android.Widget.Toast.MakeText(CrossCurrentActivity.Current.Activity, text, longDuration ? ToastLength.Long : ToastLength.Short); _toast.Show(); } public bool LaunchApp(string appName) { var activity = CrossCurrentActivity.Current.Activity; appName = appName.Replace("androidapp://", string.Empty); var launchIntent = activity.PackageManager.GetLaunchIntentForPackage(appName); if (launchIntent != null) { activity.StartActivity(launchIntent); } return launchIntent != null; } public async Task ShowLoadingAsync(string text) { if (_progressDialog != null) { await HideLoadingAsync(); } var activity = CrossCurrentActivity.Current.Activity; var inflater = (LayoutInflater)activity.GetSystemService(Context.LayoutInflaterService); var dialogView = inflater.Inflate(Resource.Layout.progress_dialog_layout, null); var txtLoading = dialogView.FindViewById(Resource.Id.txtLoading); txtLoading.Text = text; txtLoading.SetTextColor(ThemeHelpers.TextColor); _progressDialog = new AlertDialog.Builder(activity) .SetView(dialogView) .SetCancelable(false) .Create(); _progressDialog.Show(); } public Task HideLoadingAsync() { // Based on https://github.com/redth-org/AndHUD/blob/master/AndHUD/AndHUD.cs lock (_progressDialogLock) { if (_progressDialog is null) { return Task.CompletedTask; } void actionDismiss() { try { if (IsAlive(_progressDialog) && IsAlive(_progressDialog.Window)) { _progressDialog.Hide(); _progressDialog.Dismiss(); } } catch { // ignore } _progressDialog = null; } // First try the SynchronizationContext if (Application.SynchronizationContext != null) { Application.SynchronizationContext.Send(state => actionDismiss(), null); return Task.CompletedTask; } // Otherwise try OwnerActivity on dialog var ownerActivity = _progressDialog?.OwnerActivity; if (IsAlive(ownerActivity)) { ownerActivity.RunOnUiThread(actionDismiss); return Task.CompletedTask; } // Otherwise try get it from the Window Context if (_progressDialog?.Window?.Context is Activity windowActivity && IsAlive(windowActivity)) { windowActivity.RunOnUiThread(actionDismiss); return Task.CompletedTask; } // Finally if all else fails, let's see if current activity is MainActivity if (CrossCurrentActivity.Current.Activity is MainActivity activity && IsAlive(activity)) { activity.RunOnUiThread(actionDismiss); return Task.CompletedTask; } return Task.CompletedTask; } } bool IsAlive(Java.Lang.Object @object) { if (@object == null) return false; if (@object.Handle == IntPtr.Zero) return false; if (@object is Activity activity) { if (activity.IsFinishing) return false; if (activity.IsDestroyed) return false; } 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) { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; if (activity == null) { return Task.FromResult(null); } var alertBuilder = new AlertDialog.Builder(activity); alertBuilder.SetTitle(title); alertBuilder.SetMessage(description); var input = new EditText(activity) { InputType = InputTypes.ClassText }; if (text == null) { text = string.Empty; } if (numericKeyboard) { input.InputType = InputTypes.ClassNumber | InputTypes.NumberFlagDecimal | InputTypes.NumberFlagSigned; #pragma warning disable CS0618 // Type or member is obsolete input.KeyListener = DigitsKeyListener.GetInstance(false, false); #pragma warning restore CS0618 // Type or member is obsolete } if (password) { input.InputType = InputTypes.TextVariationPassword | InputTypes.ClassText; } input.ImeOptions = input.ImeOptions | (ImeAction)ImeFlags.NoPersonalizedLearning | (ImeAction)ImeFlags.NoExtractUi; input.Text = text; input.SetSelection(text.Length); var container = new FrameLayout(activity); var lp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MatchParent, LinearLayout.LayoutParams.MatchParent); lp.SetMargins(25, 0, 25, 0); input.LayoutParameters = lp; container.AddView(input); alertBuilder.SetView(container); okButtonText = okButtonText ?? AppResources.Ok; cancelButtonText = cancelButtonText ?? AppResources.Cancel; var result = new TaskCompletionSource(); alertBuilder.SetPositiveButton(okButtonText, (sender, args) => result.TrySetResult(input.Text ?? string.Empty)); alertBuilder.SetNegativeButton(cancelButtonText, (sender, args) => result.TrySetResult(null)); var alert = alertBuilder.Create(); alert.Window.SetSoftInputMode(Android.Views.SoftInput.StateVisible); alert.Show(); if (autofocus) { input.RequestFocus(); } return result.Task; } public void RateApp() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; try { var rateIntent = RateIntentForUrl("market://details", activity); activity.StartActivity(rateIntent); } catch (ActivityNotFoundException) { var rateIntent = RateIntentForUrl("https://play.google.com/store/apps/details", activity); activity.StartActivity(rateIntent); } } 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( Application.Context.PackageName, 0).VersionCode.ToString(); } public bool SupportsFaceBiometric() { // only used by iOS return false; } public Task SupportsFaceBiometricAsync() { // only used by iOS return Task.FromResult(SupportsFaceBiometric()); } public bool SupportsNfc() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; var manager = activity.GetSystemService(Context.NfcService) as NfcManager; return manager.DefaultAdapter?.IsEnabled ?? false; } public bool SupportsCamera() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; 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; } public string SystemModel() { return Build.Model; } public Task DisplayAlertAsync(string title, string message, string cancel, params string[] buttons) { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; if (activity == null) { return Task.FromResult(null); } var result = new TaskCompletionSource(); var alertBuilder = new AlertDialog.Builder(activity); alertBuilder.SetTitle(title); if (!string.IsNullOrWhiteSpace(message)) { if (buttons != null && buttons.Length > 2) { if (!string.IsNullOrWhiteSpace(title)) { alertBuilder.SetTitle($"{title}: {message}"); } else { alertBuilder.SetTitle(message); } } else { alertBuilder.SetMessage(message); } } if (buttons != null) { if (buttons.Length > 2) { alertBuilder.SetItems(buttons, (sender, args) => { result.TrySetResult(buttons[args.Which]); }); } else { if (buttons.Length > 0) { alertBuilder.SetPositiveButton(buttons[0], (sender, args) => { result.TrySetResult(buttons[0]); }); } if (buttons.Length > 1) { alertBuilder.SetNeutralButton(buttons[1], (sender, args) => { result.TrySetResult(buttons[1]); }); } } } if (!string.IsNullOrWhiteSpace(cancel)) { alertBuilder.SetNegativeButton(cancel, (sender, args) => { result.TrySetResult(cancel); }); } var alert = alertBuilder.Create(); alert.CancelEvent += (o, args) => { result.TrySetResult(null); }; alert.Show(); return result.Task; } public async Task DisplayActionSheetAsync(string title, string cancel, string destruction, params string[] buttons) { return await Xamarin.Forms.Application.Current.MainPage.DisplayActionSheet( 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() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; try { var intent = new Intent(Settings.ActionManageOverlayPermission); intent.SetData(Android.Net.Uri.Parse("package:com.x8bit.bitwarden")); activity.StartActivity(intent); } catch (ActivityNotFoundException) { // can't open overlay permission management, fall back to app settings var intent = new Intent(Settings.ActionApplicationDetailsSettings); intent.SetData(Android.Net.Uri.Parse("package:com.x8bit.bitwarden")); activity.StartActivity(intent); } catch { var alertBuilder = new AlertDialog.Builder(activity); alertBuilder.SetMessage(AppResources.BitwardenAutofillGoToSettings); alertBuilder.SetCancelable(true); alertBuilder.SetPositiveButton(AppResources.Ok, (sender, args) => { (sender as AlertDialog)?.Cancel(); }); alertBuilder.Create().Show(); } } 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 { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; var intent = new Intent(Settings.ActionAccessibilitySettings); activity.StartActivity(intent); } catch { } } public void OpenAutofillSettings() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; try { var intent = new Intent(Settings.ActionRequestSetAutofillService); intent.SetData(Android.Net.Uri.Parse("package:com.x8bit.bitwarden")); activity.StartActivity(intent); } catch (ActivityNotFoundException) { var alertBuilder = new AlertDialog.Builder(activity); alertBuilder.SetMessage(AppResources.BitwardenAutofillGoToSettings); alertBuilder.SetCancelable(true); alertBuilder.SetPositiveButton(AppResources.Ok, (sender, args) => { (sender as AlertDialog)?.Cancel(); }); alertBuilder.Create().Show(); } } public long GetActiveTime() { // Returns milliseconds since the system was booted, and includes deep sleep. This clock is guaranteed to // be monotonic, and continues to tick even when the CPU is in power saving modes, so is the recommend // basis for general purpose interval timing. // ref: https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime() return SystemClock.ElapsedRealtime(); } public void CloseMainApp() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; if (activity == null) { return; } activity.Finish(); _messagingService.Send("finishMainActivity"); } public bool SupportsFido2() { 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}")); var flags = ActivityFlags.NoHistory | ActivityFlags.MultipleTask; if ((int)Build.VERSION.SdkInt >= 21) { flags |= ActivityFlags.NewDocument; } else { // noinspection deprecation flags |= ActivityFlags.ClearWhenTaskReset; } intent.AddFlags(flags); 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; return activity?.Resources?.Configuration?.FontScale ?? 1; } public async Task OnAccountSwitchCompleteAsync() { // for any Android-specific cleanup required after switching accounts } } }