diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index dfe728ede..1ac400e27 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -152,6 +152,8 @@ + + diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index 751cd7f2a..cffc12ac0 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -36,6 +36,7 @@ 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; @@ -59,6 +60,7 @@ 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"); @@ -217,7 +219,7 @@ namespace Bit.Droid { _messagingService.Send("selectFileCameraPermissionDenied"); } - await _deviceActionService.SelectFileAsync(); + await _fileService.SelectFileAsync(); } else { diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index ef0b6f303..8bca7cafa 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -139,8 +139,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(); @@ -159,6 +160,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/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 452fb0b81..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, 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); - } - 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 }, - 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; - } - 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; 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 a314995f8..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,44 +7,32 @@ 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(); diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 483711bfd..b78085a13 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -28,6 +28,7 @@ namespace Bit.App private readonly ISyncService _syncService; private readonly IAuthService _authService; private readonly IDeviceActionService _deviceActionService; + private readonly IFileService _fileService; private readonly IAccountsManager _accountsManager; private readonly IPushNotificationService _pushNotificationService; private static bool _isResumed; @@ -49,6 +50,7 @@ namespace Bit.App _syncService = ServiceContainer.Resolve("syncService"); _authService = ServiceContainer.Resolve("authService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _fileService = ServiceContainer.Resolve(); _accountsManager = ServiceContainer.Resolve("accountsManager"); _pushNotificationService = ServiceContainer.Resolve(); @@ -301,7 +303,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/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/AutofillServicesPageViewModel.cs b/src/App/Pages/Settings/AutofillServicesPageViewModel.cs index 5af80bcf2..1ec6f16c5 100644 --- a/src/App/Pages/Settings/AutofillServicesPageViewModel.cs +++ b/src/App/Pages/Settings/AutofillServicesPageViewModel.cs @@ -12,6 +12,7 @@ namespace Bit.App.Pages public class AutofillServicesPageViewModel : BaseViewModel { private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly IStateService _stateService; private readonly MobileI18nService _i18nService; private readonly IPlatformUtilsService _platformUtilsService; @@ -26,6 +27,7 @@ namespace Bit.App.Pages public AutofillServicesPageViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); _stateService = ServiceContainer.Resolve("stateService"); _i18nService = ServiceContainer.Resolve("i18nService") as MobileI18nService; _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); @@ -173,7 +175,7 @@ namespace Bit.App.Pages } else { - _deviceActionService.DisableAutofillService(); + _autofillHandler.DisableAutofillService(); } } @@ -188,7 +190,7 @@ namespace Bit.App.Pages public async Task ToggleAccessibilityAsync() { - if (!_deviceActionService.AutofillAccessibilityServiceRunning()) + if (!_autofillHandler.AutofillAccessibilityServiceRunning()) { var accept = await _platformUtilsService.ShowDialogAsync(AppResources.AccessibilityDisclosureText, AppResources.AccessibilityServiceDisclosure, AppResources.Accept, @@ -213,9 +215,9 @@ namespace Bit.App.Pages public void UpdateEnabled() { AutofillServiceToggled = - _deviceActionService.HasAutofillService() && _deviceActionService.AutofillServiceEnabled(); - AccessibilityToggled = _deviceActionService.AutofillAccessibilityServiceRunning(); - DrawOverToggled = _deviceActionService.AutofillAccessibilityOverlayPermitted(); + _autofillHandler.SupportsAutofillService() && _autofillHandler.AutofillServiceEnabled(); + AccessibilityToggled = _autofillHandler.AutofillAccessibilityServiceRunning(); + DrawOverToggled = _autofillHandler.AutofillAccessibilityOverlayPermitted(); } private async Task UpdateInlineAutofillToggledAsync() diff --git a/src/App/Pages/Settings/ExportVaultPageViewModel.cs b/src/App/Pages/Settings/ExportVaultPageViewModel.cs index ac8b3c7f6..403cf6e8c 100644 --- a/src/App/Pages/Settings/ExportVaultPageViewModel.cs +++ b/src/App/Pages/Settings/ExportVaultPageViewModel.cs @@ -16,6 +16,7 @@ namespace Bit.App.Pages public class ExportVaultPageViewModel : BaseViewModel { private readonly IDeviceActionService _deviceActionService; + private readonly IFileService _fileService; private readonly IPlatformUtilsService _platformUtilsService; private readonly II18nService _i18nService; private readonly IExportService _exportService; @@ -39,6 +40,7 @@ namespace Bit.App.Pages public ExportVaultPageViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _fileService = ServiceContainer.Resolve(); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _i18nService = ServiceContainer.Resolve("i18nService"); _exportService = ServiceContainer.Resolve("exportService"); @@ -182,7 +184,7 @@ namespace Bit.App.Pages _defaultFilename = _exportService.GetFileName(null, fileFormat); _exportResult = Encoding.UTF8.GetBytes(data); - if (!_deviceActionService.SaveFile(_exportResult, null, _defaultFilename, null)) + if (!_fileService.SaveFile(_exportResult, null, _defaultFilename, null)) { ClearResult(); await _platformUtilsService.ShowDialogAsync(_i18nService.T("ExportVaultFailure")); @@ -220,7 +222,7 @@ namespace Bit.App.Pages public async void SaveFileSelected(string contentUri, string filename) { - if (_deviceActionService.SaveFile(_exportResult, null, filename ?? _defaultFilename, contentUri)) + if (_fileService.SaveFile(_exportResult, null, filename ?? _defaultFilename, contentUri)) { ClearResult(); _platformUtilsService.ShowToast("success", null, _i18nService.T("ExportVaultSuccess")); diff --git a/src/App/Pages/Settings/OptionsPage.xaml.cs b/src/App/Pages/Settings/OptionsPage.xaml.cs index cb07027b2..8f608d600 100644 --- a/src/App/Pages/Settings/OptionsPage.xaml.cs +++ b/src/App/Pages/Settings/OptionsPage.xaml.cs @@ -1,5 +1,6 @@ using Bit.App.Abstractions; using Bit.App.Resources; +using Bit.Core.Abstractions; using Bit.Core.Utilities; using Xamarin.Forms; using Xamarin.Forms.PlatformConfiguration; @@ -9,12 +10,12 @@ namespace Bit.App.Pages { public partial class OptionsPage : BaseContentPage { - private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly OptionsPageViewModel _vm; public OptionsPage() { - _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); InitializeComponent(); _vm = BindingContext as OptionsPageViewModel; _vm.Page = this; @@ -25,7 +26,7 @@ namespace Bit.App.Pages if (Device.RuntimePlatform == Device.Android) { ToolbarItems.RemoveAt(0); - _vm.ShowAndroidAutofillSettings = _deviceActionService.SupportsAutofillService(); + _vm.ShowAndroidAutofillSettings = _autofillHandler.SupportsAutofillService(); } else { diff --git a/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs b/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs index 54f05fc49..f586e6e79 100644 --- a/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs +++ b/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs @@ -20,6 +20,7 @@ namespace Bit.App.Pages private readonly ICryptoService _cryptoService; private readonly IStateService _stateService; private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly IEnvironmentService _environmentService; private readonly IMessagingService _messagingService; private readonly IVaultTimeoutService _vaultTimeoutService; @@ -74,6 +75,7 @@ namespace Bit.App.Pages _cryptoService = ServiceContainer.Resolve("cryptoService"); _stateService = ServiceContainer.Resolve("stateService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); _environmentService = ServiceContainer.Resolve("environmentService"); _messagingService = ServiceContainer.Resolve("messagingService"); _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); @@ -454,7 +456,7 @@ namespace Bit.App.Pages else if (await _platformUtilsService.SupportsBiometricAsync()) { _biometric = await _platformUtilsService.AuthenticateBiometricAsync(null, - _deviceActionService.DeviceType == Core.Enums.DeviceType.Android ? "." : null); + Device.RuntimePlatform == Device.Android ? "." : null); } if (_biometric == current) { @@ -485,7 +487,7 @@ namespace Bit.App.Pages autofillItems.Add(new SettingsPageListItem { Name = AppResources.AutofillServices, - SubLabel = _deviceActionService.AutofillServicesEnabled() ? AppResources.On : AppResources.Off, + SubLabel = _autofillHandler.AutofillServicesEnabled() ? AppResources.On : AppResources.Off, ExecuteAsync = () => Page.Navigation.PushModalAsync(new NavigationPage(new AutofillServicesPage(Page as SettingsPage))) }); } diff --git a/src/App/Pages/Vault/AttachmentsPageViewModel.cs b/src/App/Pages/Vault/AttachmentsPageViewModel.cs index 6216f5ec9..02e9b2ae6 100644 --- a/src/App/Pages/Vault/AttachmentsPageViewModel.cs +++ b/src/App/Pages/Vault/AttachmentsPageViewModel.cs @@ -18,6 +18,7 @@ namespace Bit.App.Pages public class AttachmentsPageViewModel : BaseViewModel { private readonly IDeviceActionService _deviceActionService; + private readonly IFileService _fileService; private readonly ICipherService _cipherService; private readonly ICryptoService _cryptoService; private readonly IStateService _stateService; @@ -34,6 +35,7 @@ namespace Bit.App.Pages public AttachmentsPageViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _fileService = ServiceContainer.Resolve(); _cipherService = ServiceContainer.Resolve("cipherService"); _cryptoService = ServiceContainer.Resolve("cryptoService"); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); @@ -156,7 +158,7 @@ namespace Bit.App.Pages { _vaultTimeoutService.DelayLockAndLogoutMs = 60000; } - await _deviceActionService.SelectFileAsync(); + await _fileService.SelectFileAsync(); } private async void DeleteAsync(AttachmentView attachment) diff --git a/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs b/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs index 80e9caf09..525f7dea1 100644 --- a/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs +++ b/src/App/Pages/Vault/AutofillCiphersPageViewModel.cs @@ -21,6 +21,7 @@ namespace Bit.App.Pages { private readonly IPlatformUtilsService _platformUtilsService; private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly ICipherService _cipherService; private readonly IStateService _stateService; private readonly IPasswordRepromptService _passwordRepromptService; @@ -37,6 +38,7 @@ namespace Bit.App.Pages _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _cipherService = ServiceContainer.Resolve("cipherService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); _stateService = ServiceContainer.Resolve("stateService"); _passwordRepromptService = ServiceContainer.Resolve("passwordRepromptService"); _messagingService = ServiceContainer.Resolve("messagingService"); @@ -232,7 +234,7 @@ namespace Bit.App.Pages } if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave) { - _deviceActionService.Autofill(cipher); + _autofillHandler.Autofill(cipher); } } } diff --git a/src/App/Pages/Vault/BaseCipherViewModel.cs b/src/App/Pages/Vault/BaseCipherViewModel.cs index bd74befa5..871d8aa24 100644 --- a/src/App/Pages/Vault/BaseCipherViewModel.cs +++ b/src/App/Pages/Vault/BaseCipherViewModel.cs @@ -14,6 +14,7 @@ namespace Bit.App.Pages { private readonly IAuditService _auditService; protected readonly IDeviceActionService _deviceActionService; + protected readonly IFileService _fileService; protected readonly ILogger _logger; protected readonly IPlatformUtilsService _platformUtilsService; private CipherView _cipher; @@ -22,6 +23,7 @@ namespace Bit.App.Pages public BaseCipherViewModel() { _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _fileService = ServiceContainer.Resolve(); _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); _auditService = ServiceContainer.Resolve("auditService"); _logger = ServiceContainer.Resolve("logger"); diff --git a/src/App/Pages/Vault/CipherAddEditPage.xaml.cs b/src/App/Pages/Vault/CipherAddEditPage.xaml.cs index d84d4a733..60e6b667d 100644 --- a/src/App/Pages/Vault/CipherAddEditPage.xaml.cs +++ b/src/App/Pages/Vault/CipherAddEditPage.xaml.cs @@ -19,6 +19,7 @@ namespace Bit.App.Pages private readonly AppOptions _appOptions; private readonly IStateService _stateService; private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly IVaultTimeoutService _vaultTimeoutService; private readonly IKeyConnectorService _keyConnectorService; @@ -40,6 +41,7 @@ namespace Bit.App.Pages { _stateService = ServiceContainer.Resolve("stateService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); _keyConnectorService = ServiceContainer.Resolve("keyConnectorService"); @@ -350,8 +352,8 @@ namespace Bit.App.Pages } } else if (Device.RuntimePlatform == Device.Android && - !_deviceActionService.AutofillAccessibilityServiceRunning() && - !_deviceActionService.AutofillServiceEnabled()) + !_autofillHandler.AutofillAccessibilityServiceRunning() && + !_autofillHandler.AutofillServiceEnabled()) { await DisplayAlert(AppResources.BitwardenAutofillService, AppResources.BitwardenAutofillServiceAlert2, AppResources.Ok); diff --git a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs index 7be04f536..4f8ea42a1 100644 --- a/src/App/Pages/Vault/CipherAddEditPageViewModel.cs +++ b/src/App/Pages/Vault/CipherAddEditPageViewModel.cs @@ -28,6 +28,7 @@ namespace Bit.App.Pages private readonly IPolicyService _policyService; private readonly ICustomFieldItemFactory _customFieldItemFactory; private readonly IClipboardService _clipboardService; + private readonly IAutofillHandler _autofillHandler; private bool _showNotesSeparator; private bool _showPassword; @@ -78,6 +79,7 @@ namespace Bit.App.Pages _policyService = ServiceContainer.Resolve("policyService"); _customFieldItemFactory = ServiceContainer.Resolve("customFieldItemFactory"); _clipboardService = ServiceContainer.Resolve("clipboardService"); + _autofillHandler = ServiceContainer.Resolve(); GeneratePasswordCommand = new Command(GeneratePassword); TogglePasswordCommand = new Command(TogglePassword); @@ -508,7 +510,7 @@ namespace Bit.App.Pages if (Page is CipherAddEditPage page && page.FromAutofillFramework) { // Close and go back to app - _deviceActionService.CloseAutofill(); + _autofillHandler.CloseAutofill(); } else { diff --git a/src/App/Pages/Vault/CipherDetailsPageViewModel.cs b/src/App/Pages/Vault/CipherDetailsPageViewModel.cs index f6e7e3452..dcd994335 100644 --- a/src/App/Pages/Vault/CipherDetailsPageViewModel.cs +++ b/src/App/Pages/Vault/CipherDetailsPageViewModel.cs @@ -493,7 +493,7 @@ namespace Bit.App.Pages } var canOpenFile = true; - if (!_deviceActionService.CanOpenFile(attachment.FileName)) + if (!_fileService.CanOpenFile(attachment.FileName)) { if (Device.RuntimePlatform == Device.iOS) { @@ -562,7 +562,7 @@ namespace Bit.App.Pages public async void OpenAttachment(byte[] data, AttachmentView attachment) { - if (!_deviceActionService.OpenFile(data, attachment.Id, attachment.FileName)) + if (!_fileService.OpenFile(data, attachment.Id, attachment.FileName)) { await _platformUtilsService.ShowDialogAsync(AppResources.UnableToOpenFile); return; @@ -573,7 +573,7 @@ namespace Bit.App.Pages { _attachmentData = data; _attachmentFilename = attachment.FileName; - if (!_deviceActionService.SaveFile(_attachmentData, null, _attachmentFilename, null)) + if (!_fileService.SaveFile(_attachmentData, null, _attachmentFilename, null)) { ClearAttachmentData(); await _platformUtilsService.ShowDialogAsync(AppResources.UnableToSaveAttachment); @@ -582,7 +582,7 @@ namespace Bit.App.Pages public async void SaveFileSelected(string contentUri, string filename) { - if (_deviceActionService.SaveFile(_attachmentData, null, filename ?? _attachmentFilename, contentUri)) + if (_fileService.SaveFile(_attachmentData, null, filename ?? _attachmentFilename, contentUri)) { ClearAttachmentData(); _platformUtilsService.ShowToast("success", null, AppResources.SaveAttachmentSuccess); diff --git a/src/App/Pages/Vault/CiphersPage.xaml.cs b/src/App/Pages/Vault/CiphersPage.xaml.cs index db97763ab..610fb826c 100644 --- a/src/App/Pages/Vault/CiphersPage.xaml.cs +++ b/src/App/Pages/Vault/CiphersPage.xaml.cs @@ -1,8 +1,8 @@ using System; using System.Linq; -using Bit.App.Abstractions; using Bit.App.Controls; using Bit.App.Resources; +using Bit.Core.Abstractions; using Bit.Core.Models.View; using Bit.Core.Utilities; using Xamarin.Forms; @@ -12,7 +12,7 @@ namespace Bit.App.Pages public partial class CiphersPage : BaseContentPage { private readonly string _autofillUrl; - private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private CiphersPageViewModel _vm; private bool _hasFocused; @@ -48,7 +48,7 @@ namespace Bit.App.Pages { NavigationPage.SetTitleView(this, _titleLayout); } - _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); } public SearchBar SearchBar => _searchBar; @@ -107,7 +107,7 @@ namespace Bit.App.Pages } else { - _deviceActionService.CloseAutofill(); + _autofillHandler.CloseAutofill(); } } diff --git a/src/App/Pages/Vault/CiphersPageViewModel.cs b/src/App/Pages/Vault/CiphersPageViewModel.cs index bbcc89d9f..aaa505d92 100644 --- a/src/App/Pages/Vault/CiphersPageViewModel.cs +++ b/src/App/Pages/Vault/CiphersPageViewModel.cs @@ -20,6 +20,7 @@ namespace Bit.App.Pages private readonly ICipherService _cipherService; private readonly ISearchService _searchService; private readonly IDeviceActionService _deviceActionService; + private readonly IAutofillHandler _autofillHandler; private readonly IStateService _stateService; private readonly IPasswordRepromptService _passwordRepromptService; private readonly IOrganizationService _organizationService; @@ -37,6 +38,7 @@ namespace Bit.App.Pages _cipherService = ServiceContainer.Resolve("cipherService"); _searchService = ServiceContainer.Resolve("searchService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); + _autofillHandler = ServiceContainer.Resolve(); _stateService = ServiceContainer.Resolve("stateService"); _passwordRepromptService = ServiceContainer.Resolve("passwordRepromptService"); _organizationService = ServiceContainer.Resolve("organizationService"); @@ -196,7 +198,7 @@ namespace Bit.App.Pages } else { - _deviceActionService.Autofill(cipher); + _autofillHandler.Autofill(cipher); } } } diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs index cd626e2ee..45014b262 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs @@ -220,7 +220,7 @@ namespace Bit.App.Pages NestedFolders = NestedFolders.GetRange(0, NestedFolders.Count - 1); } - var uppercaseGroupNames = _deviceActionService.DeviceType == DeviceType.iOS; + var uppercaseGroupNames = Device.RuntimePlatform == Device.iOS; var hasFavorites = FavoriteCiphers?.Any() ?? false; if (hasFavorites) { @@ -400,7 +400,7 @@ namespace Bit.App.Pages private void CreateCipherGroupedItems(List groupedItems) { - var uppercaseGroupNames = _deviceActionService.DeviceType == DeviceType.iOS; + var uppercaseGroupNames = Device.RuntimePlatform == Device.iOS; _totpTickCts?.Cancel(); if (ShowTotp) { diff --git a/src/App/Services/MobilePlatformUtilsService.cs b/src/App/Services/MobilePlatformUtilsService.cs index 8c74d2e5f..bea6e52f1 100644 --- a/src/App/Services/MobilePlatformUtilsService.cs +++ b/src/App/Services/MobilePlatformUtilsService.cs @@ -72,8 +72,13 @@ namespace Bit.App.Services }); } + /// + /// Gets the device type on the server enum + /// public Core.Enums.DeviceType GetDevice() { + // Can't use Device.RuntimePlatform here because it gets called before Forms.Init() and throws. + // so we need to get the DeviceType ourselves return _deviceActionService.DeviceType; } @@ -117,11 +122,6 @@ namespace Bit.App.Services } } - public void SaveFile() - { - // TODO - } - public string GetApplicationVersion() { return AppInfo.VersionString; @@ -208,11 +208,6 @@ namespace Bit.App.Services return (password, valid); } - public bool IsDev() - { - return Core.Utilities.CoreHelpers.InDebugMode(); - } - public bool IsSelfHost() { return false; diff --git a/src/App/Utilities/AppHelpers.cs b/src/App/Utilities/AppHelpers.cs index e7dff41f2..07870b0e1 100644 --- a/src/App/Utilities/AppHelpers.cs +++ b/src/App/Utilities/AppHelpers.cs @@ -564,7 +564,7 @@ namespace Bit.App.Utilities var sendService = ServiceContainer.Resolve("sendService"); var passwordGenerationService = ServiceContainer.Resolve( "passwordGenerationService"); - var deviceActionService = ServiceContainer.Resolve("deviceActionService"); + var fileService = ServiceContainer.Resolve(); var policyService = ServiceContainer.Resolve("policyService"); var searchService = ServiceContainer.Resolve("searchService"); var usernameGenerationService = ServiceContainer.Resolve( @@ -572,7 +572,7 @@ namespace Bit.App.Utilities await Task.WhenAll( cipherService.ClearCacheAsync(), - deviceActionService.ClearCacheAsync()); + fileService.ClearCacheAsync()); tokenService.ClearCache(); cryptoService.ClearCache(); settingsService.ClearCache(); diff --git a/src/Core/Abstractions/IAutofillHandler.cs b/src/Core/Abstractions/IAutofillHandler.cs new file mode 100644 index 000000000..84c9489b9 --- /dev/null +++ b/src/Core/Abstractions/IAutofillHandler.cs @@ -0,0 +1,16 @@ +using Bit.Core.Models.View; + +namespace Bit.Core.Abstractions +{ + public interface IAutofillHandler + { + bool AutofillServicesEnabled(); + bool SupportsAutofillService(); + void Autofill(CipherView cipher); + void CloseAutofill(); + bool AutofillAccessibilityServiceRunning(); + bool AutofillAccessibilityOverlayPermitted(); + bool AutofillServiceEnabled(); + void DisableAutofillService(); + } +} diff --git a/src/Core/Abstractions/IFileService.cs b/src/Core/Abstractions/IFileService.cs new file mode 100644 index 000000000..6ddf5dabc --- /dev/null +++ b/src/Core/Abstractions/IFileService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; + +namespace Bit.Core.Abstractions +{ + public interface IFileService + { + bool CanOpenFile(string fileName); + bool OpenFile(byte[] fileData, string id, string fileName); + bool SaveFile(byte[] fileData, string id, string fileName, string contentUri); + Task ClearCacheAsync(); + Task SelectFileAsync(); + } +} diff --git a/src/Core/Abstractions/IPlatformUtilsService.cs b/src/Core/Abstractions/IPlatformUtilsService.cs index e3c73d5ca..8bf2e0bed 100644 --- a/src/Core/Abstractions/IPlatformUtilsService.cs +++ b/src/Core/Abstractions/IPlatformUtilsService.cs @@ -8,15 +8,16 @@ namespace Bit.Core.Abstractions public interface IPlatformUtilsService { string GetApplicationVersion(); + /// + /// Gets the device type on the server enum + /// DeviceType GetDevice(); string GetDeviceString(); ClientType GetClientType(); - bool IsDev(); bool IsSelfHost(); bool IsViewOpen(); void LaunchUri(string uri, Dictionary options = null); Task ReadFromClipboardAsync(Dictionary options = null); - void SaveFile(); Task ShowDialogAsync(string text, string title = null, string confirmText = null, string cancelText = null, string type = null); Task ShowPasswordDialogAsync(string title, string body, Func> validator); diff --git a/src/Core/Utilities/LazyResolve.cs b/src/Core/Utilities/LazyResolve.cs index 3fedfecd0..1e8a37ee1 100644 --- a/src/Core/Utilities/LazyResolve.cs +++ b/src/Core/Utilities/LazyResolve.cs @@ -2,8 +2,13 @@ namespace Bit.Core.Utilities { - public class LazyResolve : Lazy + public class LazyResolve : Lazy where T : class { + public LazyResolve() + : base(() => ServiceContainer.Resolve()) + { + } + public LazyResolve(string containerKey) : base(() => ServiceContainer.Resolve(containerKey)) { diff --git a/src/iOS.Core/Services/AutofillHandler.cs b/src/iOS.Core/Services/AutofillHandler.cs new file mode 100644 index 000000000..ba12bed5e --- /dev/null +++ b/src/iOS.Core/Services/AutofillHandler.cs @@ -0,0 +1,22 @@ +using System; +using Bit.Core.Abstractions; +using Bit.Core.Models.View; + +namespace Bit.iOS.Core.Services +{ + /// + /// This handler is only needed on Android for now, now this class acts as a stub so that dependency injection doesn't break + /// + public class AutofillHandler : IAutofillHandler + { + public bool SupportsAutofillService() => false; + public bool AutofillServiceEnabled() => false; + public void Autofill(CipherView cipher) => throw new NotImplementedException(); + public bool AutofillAccessibilityOverlayPermitted() => throw new NotImplementedException(); + public bool AutofillAccessibilityServiceRunning() => throw new NotImplementedException(); + public bool AutofillServicesEnabled() => throw new NotImplementedException(); + public void CloseAutofill() => throw new NotImplementedException(); + public void DisableAutofillService() => throw new NotImplementedException(); + } +} + diff --git a/src/iOS.Core/Services/DeviceActionService.cs b/src/iOS.Core/Services/DeviceActionService.cs index 8f00f0b26..c2bec2753 100644 --- a/src/iOS.Core/Services/DeviceActionService.cs +++ b/src/iOS.Core/Services/DeviceActionService.cs @@ -1,20 +1,14 @@ using System; -using System.IO; using System.Linq; -using System.Net; using System.Threading.Tasks; using Bit.App.Abstractions; using Bit.App.Resources; -using Bit.Core.Abstractions; using Bit.Core.Enums; -using Bit.Core.Models.View; using Bit.iOS.Core.Utilities; using Bit.iOS.Core.Views; using CoreGraphics; using Foundation; using LocalAuthentication; -using MobileCoreServices; -using Photos; using UIKit; using Xamarin.Forms; @@ -22,20 +16,10 @@ namespace Bit.iOS.Core.Services { public class DeviceActionService : IDeviceActionService { - private readonly IStateService _stateService; - private readonly IMessagingService _messagingService; private Toast _toast; private UIAlertController _progressAlert; private string _userAgent; - public DeviceActionService( - IStateService stateService, - IMessagingService messagingService) - { - _stateService = stateService; - _messagingService = messagingService; - } - public string DeviceUserAgent { get @@ -120,91 +104,6 @@ namespace Bit.iOS.Core.Services return result.Task; } - public bool OpenFile(byte[] fileData, string id, string fileName) - { - var filePath = Path.Combine(GetTempPath(), fileName); - File.WriteAllBytes(filePath, fileData); - var url = NSUrl.FromFilename(filePath); - var viewer = UIDocumentInteractionController.FromUrl(url); - var controller = GetVisibleViewController(); - var rect = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad ? - new CGRect(100, 5, 320, 320) : controller.View.Frame; - return viewer.PresentOpenInMenu(rect, controller.View, true); - } - - public bool CanOpenFile(string fileName) - { - // Not sure of a way to check this ahead of time on iOS - return true; - } - - public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri) - { - // OpenFile behavior is appropriate here as iOS prompts to save file - return OpenFile(fileData, id, fileName); - } - - public async Task ClearCacheAsync() - { - var url = new NSUrl(GetTempPath()); - var tmpFiles = NSFileManager.DefaultManager.GetDirectoryContent(url, null, - NSDirectoryEnumerationOptions.SkipsHiddenFiles, out NSError error); - if (error == null && tmpFiles.Length > 0) - { - foreach (var item in tmpFiles) - { - NSFileManager.DefaultManager.Remove(item, out NSError itemError); - } - } - await _stateService.SetLastFileCacheClearAsync(DateTime.UtcNow); - } - - public Task SelectFileAsync() - { - var controller = GetVisibleViewController(); - var picker = new UIDocumentMenuViewController(new string[] { UTType.Data }, UIDocumentPickerMode.Import); - picker.AddOption(AppResources.Camera, UIImage.FromBundle("camera"), UIDocumentMenuOrder.First, () => - { - var imagePicker = new UIImagePickerController - { - SourceType = UIImagePickerControllerSourceType.Camera - }; - imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; - imagePicker.Canceled += ImagePicker_Canceled; - controller.PresentModalViewController(imagePicker, true); - }); - picker.AddOption(AppResources.Photos, UIImage.FromBundle("photo"), UIDocumentMenuOrder.First, () => - { - var imagePicker = new UIImagePickerController - { - SourceType = UIImagePickerControllerSourceType.PhotoLibrary - }; - imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; - imagePicker.Canceled += ImagePicker_Canceled; - controller.PresentModalViewController(imagePicker, true); - }); - picker.DidPickDocumentPicker += (sender, e) => - { - if (SystemMajorVersion() < 11) - { - e.DocumentPicker.DidPickDocument += DocumentPicker_DidPickDocument; - } - else - { - e.DocumentPicker.Delegate = new PickerDelegate(this); - } - controller.PresentViewController(e.DocumentPicker, true, null); - }; - var root = UIApplication.SharedApplication.KeyWindow.RootViewController; - if (picker.PopoverPresentationController != null && root != null) - { - picker.PopoverPresentationController.SourceView = root.View; - picker.PopoverPresentationController.SourceRect = root.View.Bounds; - } - controller.PresentViewController(picker, true, null); - 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) @@ -298,11 +197,6 @@ namespace Bit.iOS.Core.Services return true; } - public bool SupportsAutofillService() - { - return true; - } - public int SystemMajorVersion() { var versionParts = UIDevice.CurrentDevice.SystemVersion.Split('.'); @@ -391,46 +285,6 @@ namespace Bit.iOS.Core.Services return result.Task; } - public void Autofill(CipherView cipher) - { - throw new NotImplementedException(); - } - - public void CloseAutofill() - { - throw new NotImplementedException(); - } - - public void Background() - { - throw new NotImplementedException(); - } - - public bool AutofillAccessibilityServiceRunning() - { - throw new NotImplementedException(); - } - - public bool HasAutofillService() - { - return false; - } - - public bool AutofillServiceEnabled() - { - throw new NotImplementedException(); - } - - public void DisableAutofillService() - { - throw new NotImplementedException(); - } - - public bool AutofillServicesEnabled() - { - throw new NotImplementedException(); - } - public string GetBuildNumber() { return NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString(); @@ -479,78 +333,6 @@ namespace Bit.iOS.Core.Services return false; } - private void ImagePicker_FinishedPickingMedia(object sender, UIImagePickerMediaPickedEventArgs e) - { - if (sender is UIImagePickerController picker) - { - string fileName = null; - if (e.Info.TryGetValue(UIImagePickerController.ReferenceUrl, out NSObject urlObj)) - { - var result = PHAsset.FetchAssets(new NSUrl[] { (urlObj as NSUrl) }, null); - fileName = result?.firstObject?.ValueForKey(new NSString("filename"))?.ToString(); - } - fileName = fileName ?? $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg"; - var lowerFilename = fileName?.ToLowerInvariant(); - byte[] data; - if (lowerFilename != null && (lowerFilename.EndsWith(".jpg") || lowerFilename.EndsWith(".jpeg"))) - { - using (var imageData = e.OriginalImage.AsJPEG()) - { - data = new byte[imageData.Length]; - System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, - Convert.ToInt32(imageData.Length)); - } - } - else - { - using (var imageData = e.OriginalImage.AsPNG()) - { - data = new byte[imageData.Length]; - System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, - Convert.ToInt32(imageData.Length)); - } - } - SelectFileResult(data, fileName); - picker.DismissViewController(true, null); - } - } - - private void ImagePicker_Canceled(object sender, EventArgs e) - { - if (sender is UIImagePickerController picker) - { - picker.DismissViewController(true, null); - } - } - - private void DocumentPicker_DidPickDocument(object sender, UIDocumentPickedEventArgs e) - { - PickedDocument(e.Url); - } - - private void SelectFileResult(byte[] data, string fileName) - { - _messagingService.Send("selectFileResult", new Tuple(data, fileName)); - } - - private UIViewController GetVisibleViewController(UIViewController controller = null) - { - controller = controller ?? UIApplication.SharedApplication.KeyWindow.RootViewController; - if (controller.PresentedViewController == null) - { - return controller; - } - if (controller.PresentedViewController is UINavigationController) - { - return ((UINavigationController)controller.PresentedViewController).VisibleViewController; - } - if (controller.PresentedViewController is UITabBarController) - { - return ((UITabBarController)controller.PresentedViewController).SelectedViewController; - } - return GetVisibleViewController(controller.PresentedViewController); - } - private UIViewController GetPresentedViewController() { var window = UIApplication.SharedApplication.KeyWindow; @@ -569,43 +351,6 @@ namespace Bit.iOS.Core.Services (vc.ChildViewControllers?.Any(c => c is UITabBarController) ?? false)); } - // ref: //https://developer.xamarin.com/guides/ios/application_fundamentals/working_with_the_file_system/ - public string GetTempPath() - { - var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - return Path.Combine(documents, "..", "tmp"); - } - - public void PickedDocument(NSUrl url) - { - url.StartAccessingSecurityScopedResource(); - var doc = new UIDocument(url); - var fileName = doc.LocalizedName; - if (string.IsNullOrWhiteSpace(fileName)) - { - var path = doc.FileUrl?.ToString(); - if (path != null) - { - path = WebUtility.UrlDecode(path); - var split = path.LastIndexOf('/'); - fileName = path.Substring(split + 1); - } - } - var fileCoordinator = new NSFileCoordinator(); - fileCoordinator.CoordinateRead(url, NSFileCoordinatorReadingOptions.WithoutChanges, - out NSError error, (u) => - { - var data = NSData.FromUrl(u).ToArray(); - SelectFileResult(data, fileName ?? "unknown_file_name"); - }); - url.StopAccessingSecurityScopedResource(); - } - - public bool AutofillAccessibilityOverlayPermitted() - { - throw new NotImplementedException(); - } - public void OpenAccessibilityOverlayPermissionSettings() { throw new NotImplementedException(); @@ -629,21 +374,6 @@ namespace Bit.iOS.Core.Services return Task.CompletedTask; } - public class PickerDelegate : UIDocumentPickerDelegate - { - private readonly DeviceActionService _deviceActionService; - - public PickerDelegate(DeviceActionService deviceActionService) - { - _deviceActionService = deviceActionService; - } - - public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl url) - { - _deviceActionService.PickedDocument(url); - } - } - public void OpenAppSettings() { var url = new NSUrl(UIApplication.OpenSettingsUrlString); diff --git a/src/iOS.Core/Services/FileService.cs b/src/iOS.Core/Services/FileService.cs new file mode 100644 index 000000000..bac060824 --- /dev/null +++ b/src/iOS.Core/Services/FileService.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Bit.App.Resources; +using Bit.Core.Abstractions; +using Bit.iOS.Core.Utilities; +using CoreGraphics; +using Foundation; +using MobileCoreServices; +using Photos; +using UIKit; + +namespace Bit.iOS.Core.Services +{ + public class FileService : IFileService + { + private readonly IStateService _stateService; + private readonly IMessagingService _messagingService; + + public FileService(IStateService stateService, IMessagingService messagingService) + { + _stateService = stateService; + _messagingService = messagingService; + } + + public bool OpenFile(byte[] fileData, string id, string fileName) + { + var filePath = Path.Combine(GetTempPath(), fileName); + File.WriteAllBytes(filePath, fileData); + var url = NSUrl.FromFilename(filePath); + var viewer = UIDocumentInteractionController.FromUrl(url); + var controller = UIViewControllerExtensions.GetVisibleViewController(); + var rect = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad ? + new CGRect(100, 5, 320, 320) : controller.View.Frame; + return viewer.PresentOpenInMenu(rect, controller.View, true); + } + + public bool CanOpenFile(string fileName) + { + // Not sure of a way to check this ahead of time on iOS + return true; + } + + public bool SaveFile(byte[] fileData, string id, string fileName, string contentUri) + { + // OpenFile behavior is appropriate here as iOS prompts to save file + return OpenFile(fileData, id, fileName); + } + + public async Task ClearCacheAsync() + { + var url = new NSUrl(GetTempPath()); + var tmpFiles = NSFileManager.DefaultManager.GetDirectoryContent(url, null, + NSDirectoryEnumerationOptions.SkipsHiddenFiles, out NSError error); + if (error == null && tmpFiles.Length > 0) + { + foreach (var item in tmpFiles) + { + NSFileManager.DefaultManager.Remove(item, out NSError itemError); + } + } + await _stateService.SetLastFileCacheClearAsync(DateTime.UtcNow); + } + + public Task SelectFileAsync() + { + var controller = UIViewControllerExtensions.GetVisibleViewController(); + var picker = new UIDocumentMenuViewController(new string[] { UTType.Data }, UIDocumentPickerMode.Import); + picker.AddOption(AppResources.Camera, UIImage.FromBundle("camera"), UIDocumentMenuOrder.First, () => + { + var imagePicker = new UIImagePickerController + { + SourceType = UIImagePickerControllerSourceType.Camera + }; + imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; + imagePicker.Canceled += ImagePicker_Canceled; + controller.PresentModalViewController(imagePicker, true); + }); + picker.AddOption(AppResources.Photos, UIImage.FromBundle("photo"), UIDocumentMenuOrder.First, () => + { + var imagePicker = new UIImagePickerController + { + SourceType = UIImagePickerControllerSourceType.PhotoLibrary + }; + imagePicker.FinishedPickingMedia += ImagePicker_FinishedPickingMedia; + imagePicker.Canceled += ImagePicker_Canceled; + controller.PresentModalViewController(imagePicker, true); + }); + picker.DidPickDocumentPicker += (sender, e) => + { + if (UIDevice.CurrentDevice.CheckSystemVersion(11, 0)) + { + e.DocumentPicker.Delegate = new PickerDelegate(this); + } + else + { + e.DocumentPicker.DidPickDocument += DocumentPicker_DidPickDocument; + } + controller.PresentViewController(e.DocumentPicker, true, null); + }; + var root = UIApplication.SharedApplication.KeyWindow.RootViewController; + if (picker.PopoverPresentationController != null && root != null) + { + picker.PopoverPresentationController.SourceView = root.View; + picker.PopoverPresentationController.SourceRect = root.View.Bounds; + } + controller.PresentViewController(picker, true, null); + return Task.CompletedTask; + } + + // ref: //https://developer.xamarin.com/guides/ios/application_fundamentals/working_with_the_file_system/ + public string GetTempPath() + { + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return Path.Combine(documents, "..", "tmp"); + } + + private void ImagePicker_FinishedPickingMedia(object sender, UIImagePickerMediaPickedEventArgs e) + { + if (sender is UIImagePickerController picker) + { + string fileName = null; + if (e.Info.TryGetValue(UIImagePickerController.ReferenceUrl, out NSObject urlObj)) + { + var result = PHAsset.FetchAssets(new NSUrl[] { (urlObj as NSUrl) }, null); + fileName = result?.firstObject?.ValueForKey(new NSString("filename"))?.ToString(); + } + fileName = fileName ?? $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg"; + var lowerFilename = fileName?.ToLowerInvariant(); + byte[] data; + if (lowerFilename != null && (lowerFilename.EndsWith(".jpg") || lowerFilename.EndsWith(".jpeg"))) + { + using (var imageData = e.OriginalImage.AsJPEG()) + { + data = new byte[imageData.Length]; + System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, + Convert.ToInt32(imageData.Length)); + } + } + else + { + using (var imageData = e.OriginalImage.AsPNG()) + { + data = new byte[imageData.Length]; + System.Runtime.InteropServices.Marshal.Copy(imageData.Bytes, data, 0, + Convert.ToInt32(imageData.Length)); + } + } + SelectFileResult(data, fileName); + picker.DismissViewController(true, null); + } + } + + private void ImagePicker_Canceled(object sender, EventArgs e) + { + if (sender is UIImagePickerController picker) + { + picker.DismissViewController(true, null); + } + } + + private void DocumentPicker_DidPickDocument(object sender, UIDocumentPickedEventArgs e) + { + PickedDocument(e.Url); + } + + public void PickedDocument(NSUrl url) + { + url.StartAccessingSecurityScopedResource(); + var doc = new UIDocument(url); + var fileName = doc.LocalizedName; + if (string.IsNullOrWhiteSpace(fileName)) + { + var path = doc.FileUrl?.ToString(); + if (path != null) + { + path = WebUtility.UrlDecode(path); + var split = path.LastIndexOf('/'); + fileName = path.Substring(split + 1); + } + } + var fileCoordinator = new NSFileCoordinator(); + fileCoordinator.CoordinateRead(url, NSFileCoordinatorReadingOptions.WithoutChanges, + out NSError error, (u) => + { + var data = NSData.FromUrl(u).ToArray(); + SelectFileResult(data, fileName ?? "unknown_file_name"); + }); + url.StopAccessingSecurityScopedResource(); + } + + private void SelectFileResult(byte[] data, string fileName) + { + _messagingService.Send("selectFileResult", new Tuple(data, fileName)); + } + + public class PickerDelegate : UIDocumentPickerDelegate + { + private readonly FileService _fileService; + + public PickerDelegate(FileService fileService) + { + _fileService = fileService; + } + + public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl url) + { + _fileService.PickedDocument(url); + } + } + } +} diff --git a/src/iOS.Core/Utilities/UIViewControllerExtensions.cs b/src/iOS.Core/Utilities/UIViewControllerExtensions.cs new file mode 100644 index 000000000..c29025813 --- /dev/null +++ b/src/iOS.Core/Utilities/UIViewControllerExtensions.cs @@ -0,0 +1,31 @@ +using System; +using UIKit; + +namespace Bit.iOS.Core.Utilities +{ + public static class UIViewControllerExtensions + { + public static UIViewController GetVisibleViewController() + { + return GetVisibleViewController(UIApplication.SharedApplication.KeyWindow.RootViewController); + } + + public static UIViewController GetVisibleViewController(this UIViewController controller) + { + if (controller?.PresentedViewController == null) + { + return controller; + } + if (controller.PresentedViewController is UINavigationController) + { + return ((UINavigationController)controller.PresentedViewController).VisibleViewController; + } + if (controller.PresentedViewController is UITabBarController) + { + return ((UITabBarController)controller.PresentedViewController).SelectedViewController; + } + return GetVisibleViewController(controller.PresentedViewController); + } + } +} + diff --git a/src/iOS.Core/Utilities/iOSCoreHelpers.cs b/src/iOS.Core/Utilities/iOSCoreHelpers.cs index 3d7b556ef..4d6a13c6c 100644 --- a/src/iOS.Core/Utilities/iOSCoreHelpers.cs +++ b/src/iOS.Core/Utilities/iOSCoreHelpers.cs @@ -102,7 +102,8 @@ namespace Bit.iOS.Core.Utilities var stateService = new StateService(mobileStorageService, secureStorageService, messagingService); var stateMigrationService = new StateMigrationService(liteDbStorage, preferencesStorage, secureStorageService); - var deviceActionService = new DeviceActionService(stateService, messagingService); + var deviceActionService = new DeviceActionService(); + var fileService = new FileService(stateService, messagingService); var clipboardService = new ClipboardService(stateService); var platformUtilsService = new MobilePlatformUtilsService(deviceActionService, clipboardService, messagingService, broadcasterService); @@ -121,6 +122,8 @@ namespace Bit.iOS.Core.Utilities ServiceContainer.Register("stateService", stateService); ServiceContainer.Register("stateMigrationService", stateMigrationService); ServiceContainer.Register("deviceActionService", deviceActionService); + ServiceContainer.Register(fileService); + ServiceContainer.Register(new AutofillHandler()); ServiceContainer.Register("clipboardService", clipboardService); ServiceContainer.Register("platformUtilsService", platformUtilsService); ServiceContainer.Register("biometricService", biometricService); diff --git a/src/iOS.Core/iOS.Core.csproj b/src/iOS.Core/iOS.Core.csproj index 76114db6a..a4515c9b8 100644 --- a/src/iOS.Core/iOS.Core.csproj +++ b/src/iOS.Core/iOS.Core.csproj @@ -204,6 +204,9 @@ + + +