diff --git a/src/Android/MainActivity.cs b/src/Android/MainActivity.cs index b5d5a0ff5..55eacba73 100644 --- a/src/Android/MainActivity.cs +++ b/src/Android/MainActivity.cs @@ -12,6 +12,7 @@ using Android.Runtime; using Android.Views; using Bit.App.Abstractions; using Bit.App.Models; +using Bit.App.Resources; using Bit.App.Utilities; using Bit.Core; using Bit.Core.Abstractions; @@ -86,6 +87,7 @@ namespace Bit.Droid Xamarin.Essentials.Platform.Init(this, savedInstanceState); Xamarin.Forms.Forms.Init(this, savedInstanceState); _appOptions = GetOptions(); + CreateNotificationChannel(); LoadApplication(new App.App(_appOptions)); DisableAndroidFontScale(); @@ -407,6 +409,25 @@ namespace Bit.Droid await _eventService.UploadEventsAsync(); } + private void CreateNotificationChannel() + { +#if !FDROID + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + // Notification channels are new in API 26 (and not a part of the + // support library). There is no need to create a notification + // channel on older versions of Android. + return; + } + + var channel = new NotificationChannel(Constants.AndroidNotificationChannelId, AppResources.AllNotifications, NotificationImportance.Default); + if(GetSystemService(NotificationService) is NotificationManager notificationManager) + { + notificationManager.CreateNotificationChannel(channel); + } +#endif + } + private void DisableAndroidFontScale() { try diff --git a/src/Android/Push/FirebaseMessagingService.cs b/src/Android/Push/FirebaseMessagingService.cs index 676390aef..887c8ac44 100644 --- a/src/Android/Push/FirebaseMessagingService.cs +++ b/src/Android/Push/FirebaseMessagingService.cs @@ -1,7 +1,9 @@ #if !FDROID +using System; using Android.App; using Bit.App.Abstractions; using Bit.Core.Abstractions; +using Bit.Core.Services; using Bit.Core.Utilities; using Firebase.Messaging; using Newtonsoft.Json; @@ -16,34 +18,41 @@ namespace Bit.Droid.Push { public async override void OnNewToken(string token) { - var stateService = ServiceContainer.Resolve("stateService"); - var pushNotificationService = ServiceContainer.Resolve("pushNotificationService"); + try { + var stateService = ServiceContainer.Resolve("stateService"); + var pushNotificationService = ServiceContainer.Resolve("pushNotificationService"); - await stateService.SetPushRegisteredTokenAsync(token); - await pushNotificationService.RegisterAsync(); + await stateService.SetPushRegisteredTokenAsync(token); + await pushNotificationService.RegisterAsync(); + } + catch (Exception ex) + { + Logger.Instance.Exception(ex); + } } public async override void OnMessageReceived(RemoteMessage message) { - if (message?.Data == null) - { - return; - } - var data = message.Data.ContainsKey("data") ? message.Data["data"] : null; - if (data == null) - { - return; - } try { + if (message?.Data == null) + { + return; + } + var data = message.Data.ContainsKey("data") ? message.Data["data"] : null; + if (data == null) + { + return; + } + var obj = JObject.Parse(data); var listener = ServiceContainer.Resolve( "pushNotificationListenerService"); await listener.OnMessageAsync(obj, Device.Android); } - catch (JsonReaderException ex) + catch (Exception ex) { - System.Diagnostics.Debug.WriteLine(ex.ToString()); + Logger.Instance.Exception(ex); } } } diff --git a/src/Android/Services/AndroidPushNotificationService.cs b/src/Android/Services/AndroidPushNotificationService.cs index e871393f6..b8088cb80 100644 --- a/src/Android/Services/AndroidPushNotificationService.cs +++ b/src/Android/Services/AndroidPushNotificationService.cs @@ -1,8 +1,11 @@ #if !FDROID using System; using System.Threading.Tasks; +using Android.App; +using Android.Content; using AndroidX.Core.App; using Bit.App.Abstractions; +using Bit.Core; using Bit.Core.Abstractions; using Xamarin.Forms; @@ -23,6 +26,11 @@ namespace Bit.Droid.Services public bool IsRegisteredForPush => NotificationManagerCompat.From(Android.App.Application.Context)?.AreNotificationsEnabled() ?? false; + public Task AreNotificationsSettingsEnabledAsync() + { + return Task.FromResult(IsRegisteredForPush); + } + public async Task GetTokenAsync() { return await _stateService.GetPushCurrentTokenAsync(); @@ -47,6 +55,36 @@ namespace Bit.Droid.Services // Do we ever need to unregister? return Task.FromResult(0); } + + public void DismissLocalNotification(string notificationId) + { + if (int.TryParse(notificationId, out int intNotificationId)) + { + var notificationManager = NotificationManagerCompat.From(Android.App.Application.Context); + notificationManager.Cancel(intNotificationId); + } + } + + public void SendLocalNotification(string title, string message, string notificationId) + { + if (string.IsNullOrEmpty(notificationId)) + { + throw new ArgumentNullException("notificationId cannot be null or empty."); + } + + var context = Android.App.Application.Context; + var intent = new Intent(context, typeof(MainActivity)); + var pendingIntent = PendingIntent.GetActivity(context, 20220801, intent, PendingIntentFlags.UpdateCurrent); + var builder = new NotificationCompat.Builder(context, Constants.AndroidNotificationChannelId) + .SetContentIntent(pendingIntent) + .SetContentTitle(title) + .SetContentText(message) + .SetSmallIcon(Resource.Mipmap.ic_launcher) + .SetAutoCancel(true); + + var notificationManager = NotificationManagerCompat.From(context); + notificationManager.Notify(int.Parse(notificationId), builder.Build()); + } } } #endif diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index c981844ca..a66a3370c 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -964,5 +964,14 @@ namespace Bit.Droid.Services } activity.RunOnUiThread(() => activity.Window.AddFlags(WindowManagerFlags.Secure)); } + + public void OpenAppSettings() + { + var intent = new Intent(Android.Provider.Settings.ActionApplicationDetailsSettings); + intent.AddFlags(ActivityFlags.NewTask); + var uri = Android.Net.Uri.FromParts("package", Application.Context.PackageName, null); + intent.SetData(uri); + Application.Context.StartActivity(intent); + } } } diff --git a/src/App/Abstractions/IDeviceActionService.cs b/src/App/Abstractions/IDeviceActionService.cs index 1c7f75941..a314995f8 100644 --- a/src/App/Abstractions/IDeviceActionService.cs +++ b/src/App/Abstractions/IDeviceActionService.cs @@ -49,5 +49,6 @@ namespace Bit.App.Abstractions float GetSystemFontSizeScale(); Task OnAccountSwitchCompleteAsync(); Task SetScreenCaptureAllowedAsync(); + void OpenAppSettings(); } } diff --git a/src/App/Abstractions/IPushNotificationService.cs b/src/App/Abstractions/IPushNotificationService.cs index c4e3827cb..f0d56691e 100644 --- a/src/App/Abstractions/IPushNotificationService.cs +++ b/src/App/Abstractions/IPushNotificationService.cs @@ -1,12 +1,16 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; namespace Bit.App.Abstractions { public interface IPushNotificationService { bool IsRegisteredForPush { get; } + Task AreNotificationsSettingsEnabledAsync(); Task GetTokenAsync(); Task RegisterAsync(); Task UnregisterAsync(); + void SendLocalNotification(string title, string message, string notificationId); + void DismissLocalNotification(string notificationId); } } diff --git a/src/App/App.csproj b/src/App/App.csproj index 1c0e612d5..0a0eb15e6 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -123,6 +123,9 @@ Code + + LoginPasswordlessPage.xaml + diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 6870f20f2..c83ab3308 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -7,6 +7,7 @@ using Bit.App.Resources; using Bit.App.Services; using Bit.App.Utilities; using Bit.App.Utilities.AccountManagement; +using Bit.Core; using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -25,13 +26,13 @@ namespace Bit.App private readonly IStateService _stateService; private readonly IVaultTimeoutService _vaultTimeoutService; private readonly ISyncService _syncService; - private readonly IPlatformUtilsService _platformUtilsService; private readonly IAuthService _authService; - private readonly IStorageService _secureStorageService; private readonly IDeviceActionService _deviceActionService; private readonly IAccountsManager _accountsManager; - + private readonly IPushNotificationService _pushNotificationService; private static bool _isResumed; + // this variable is static because the app is launching new activities on notification click, creating new instances of App. + private static bool _pendingCheckPasswordlessLoginRequests; public App(AppOptions appOptions) { @@ -47,10 +48,9 @@ namespace Bit.App _vaultTimeoutService = ServiceContainer.Resolve("vaultTimeoutService"); _syncService = ServiceContainer.Resolve("syncService"); _authService = ServiceContainer.Resolve("authService"); - _platformUtilsService = ServiceContainer.Resolve("platformUtilsService"); - _secureStorageService = ServiceContainer.Resolve("secureStorageService"); _deviceActionService = ServiceContainer.Resolve("deviceActionService"); _accountsManager = ServiceContainer.Resolve("accountsManager"); + _pushNotificationService = ServiceContainer.Resolve(); _accountsManager.Init(() => Options, this); @@ -140,6 +140,10 @@ namespace Bit.App new NavigationPage(new RemoveMasterPasswordPage())); }); } + else if (message.Command == "passwordlessLoginRequest" || message.Command == "unlocked") + { + CheckPasswordlessLoginRequestsAsync().FireAndForget(); + } } catch (Exception ex) { @@ -148,11 +152,52 @@ namespace Bit.App }); } + private async Task CheckPasswordlessLoginRequestsAsync() + { + if (!_isResumed) + { + _pendingCheckPasswordlessLoginRequests = true; + return; + } + + _pendingCheckPasswordlessLoginRequests = false; + if (await _vaultTimeoutService.IsLockedAsync()) + { + return; + } + + + var notification = await _stateService.GetPasswordlessLoginNotificationAsync(); + if (notification == null) + { + return; + } + + // Delay to wait for the vault page to appear + await Task.Delay(2000); + var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(notification.Id); + var page = new LoginPasswordlessPage(new LoginPasswordlessDetails() + { + PubKey = loginRequestData.PublicKey, + Id = loginRequestData.Id, + IpAddress = loginRequestData.RequestIpAddress, + Email = await _stateService.GetEmailAsync(), + FingerprintPhrase = loginRequestData.RequestFingerprint, + RequestDate = loginRequestData.CreationDate, + DeviceType = loginRequestData.RequestDeviceType, + Origin = loginRequestData.Origin, + }); + await _stateService.SetPasswordlessLoginNotificationAsync(null); + _pushNotificationService.DismissLocalNotification(Constants.PasswordlessNotificationId); + await Device.InvokeOnMainThreadAsync(async () => await Application.Current.MainPage.Navigation.PushModalAsync(new NavigationPage(page))); + } + public AppOptions Options { get; private set; } protected async override void OnStart() { System.Diagnostics.Debug.WriteLine("XF App: OnStart"); + _isResumed = true; await ClearCacheIfNeededAsync(); Prime(); if (string.IsNullOrWhiteSpace(Options.Uri)) @@ -164,6 +209,10 @@ namespace Bit.App SyncIfNeeded(); } } + if (_pendingCheckPasswordlessLoginRequests) + { + CheckPasswordlessLoginRequestsAsync().FireAndForget(); + } if (Device.RuntimePlatform == Device.Android) { await _vaultTimeoutService.CheckVaultTimeoutAsync(); @@ -196,6 +245,10 @@ namespace Bit.App { System.Diagnostics.Debug.WriteLine("XF App: OnResume"); _isResumed = true; + if (_pendingCheckPasswordlessLoginRequests) + { + CheckPasswordlessLoginRequestsAsync().FireAndForget(); + } if (Device.RuntimePlatform == Device.Android) { ResumedAsync().FireAndForget(); diff --git a/src/App/Pages/Accounts/LoginPasswordlessPage.xaml b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml new file mode 100644 index 000000000..83361c29c --- /dev/null +++ b/src/App/Pages/Accounts/LoginPasswordlessPage.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + +