From b15dc065be0ba99529b34f7ae982367cd459f054 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 1 Feb 2024 00:33:10 -0600 Subject: [PATCH] Handle navigation for auth requests from notification (#934) --- app/src/main/AndroidManifest.xml | 4 -- .../java/com/x8bit/bitwarden/MainViewModel.kt | 12 ++++ .../AuthRequestNotificationManagerImpl.kt | 9 +-- .../auth/util/PasswordlessRequestDataUtils.kt | 26 ++++++++ .../manager/model/SpecialCircumstance.kt | 9 +++ .../util/SpecialCircumstanceExtensions.kt | 2 + .../platform/feature/rootnav/RootNavScreen.kt | 10 +++ .../feature/rootnav/RootNavViewModel.kt | 10 +++ .../loginapproval/LoginApprovalNavigation.kt | 19 ++++-- .../loginapproval/LoginApprovalViewModel.kt | 2 +- .../com/x8bit/bitwarden/MainViewModelTest.kt | 62 +++++++++++++++++++ 11 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/util/PasswordlessRequestDataUtils.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03b8a17ff6..38fcc9ef57 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,10 +44,6 @@ - - - - { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.PasswordlessRequest( + passwordlessRequestData = passwordlessRequestData, + // Allow users back into the already-running app when completing the + // autofill task when this is not the first intent. + shouldFinishWhenComplete = isFirstIntent, + ) + } + autofillSaveItem != null -> { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSave( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestNotificationManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestNotificationManagerImpl.kt index a0a26b7dc5..7878d59164 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestNotificationManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestNotificationManagerImpl.kt @@ -3,14 +3,13 @@ package com.x8bit.bitwarden.data.auth.manager import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context -import android.content.Intent import androidx.compose.ui.graphics.Color import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.x8bit.bitwarden.MainActivity import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.util.createPasswordlessRequestDataIntent import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -79,9 +78,7 @@ class AuthRequestNotificationManagerImpl( PendingIntent.getActivity( context, NOTIFICATION_REQUEST_CODE, - Intent(context, MainActivity::class.java) - .setAction(NOTIFICATION_ACTION) - .putExtra(NOTIFICATION_DATA, data), + createPasswordlessRequestDataIntent(context, data), PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(), ) @@ -101,9 +98,7 @@ class AuthRequestNotificationManagerImpl( ?: NotificationManagerCompat.IMPORTANCE_DEFAULT } -const val NOTIFICATION_ACTION: String = "com.x8bit.bitwarden.data.auth.manager.AUTH_REQUEST" private const val NOTIFICATION_CHANNEL_ID: String = "general_notification_channel" private const val NOTIFICATION_ID: Int = 2_6072_022 -private const val NOTIFICATION_DATA: String = "notificationData" private const val NOTIFICATION_REQUEST_CODE: Int = 20220801 private const val NOTIFICATION_DEFAULT_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/util/PasswordlessRequestDataUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/PasswordlessRequestDataUtils.kt new file mode 100644 index 0000000000..3b8b4957ea --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/PasswordlessRequestDataUtils.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.auth.util + +import android.content.Context +import android.content.Intent +import com.x8bit.bitwarden.MainActivity +import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData +import com.x8bit.bitwarden.data.platform.util.getSafeParcelableExtra + +private const val NOTIFICATION_DATA: String = "notificationData" + +/** + * Creates an [Intent] that can be used to navigate the pending auth approval screen. + */ +fun createPasswordlessRequestDataIntent( + context: Context, + data: PasswordlessRequestData, +): Intent = + Intent(context, MainActivity::class.java) + .putExtra(NOTIFICATION_DATA, data) + +/** + * Checks if the given [Intent] contains data for passwordless authorization. + * The [PasswordlessRequestData] will be returned when present. + */ +fun Intent.getPasswordlessRequestDataIntentOrNull(): PasswordlessRequestData? = + this.getSafeParcelableExtra(NOTIFICATION_DATA) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 113a75c744..239692e017 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -37,4 +37,13 @@ sealed class SpecialCircumstance : Parcelable { val autofillSelectionData: AutofillSelectionData, val shouldFinishWhenComplete: Boolean, ) : SpecialCircumstance() + + /** + * The app was launched in order to allow the user to authorize a passwordless login. + */ + @Parcelize + data class PasswordlessRequest( + val passwordlessRequestData: PasswordlessRequestData, + val shouldFinishWhenComplete: Boolean, + ) : SpecialCircumstance() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 4ec48589ba..9e3ae63ffb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -11,6 +11,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? = when (this) { is SpecialCircumstance.AutofillSave -> this.autofillSaveItem is SpecialCircumstance.AutofillSelection -> null + is SpecialCircumstance.PasswordlessRequest -> null is SpecialCircumstance.ShareNewSend -> null } @@ -21,5 +22,6 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData? when (this) { is SpecialCircumstance.AutofillSave -> null is SpecialCircumstance.AutofillSelection -> this.autofillSelectionData + is SpecialCircumstance.PasswordlessRequest -> null is SpecialCircumstance.ShareNewSend -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 67b99dbf97..c1aab732d0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -21,6 +21,7 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination import com.x8bit.bitwarden.ui.platform.feature.rootnav.util.toVaultItemListingType +import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination @@ -90,6 +91,7 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForAutofillSave, is RootNavState.VaultUnlockedForAutofillSelection, is RootNavState.VaultUnlockedForNewSend, + is RootNavState.VaultUnlockedForAuthRequest, -> VAULT_UNLOCKED_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -144,6 +146,14 @@ fun RootNavScreen( navOptions = rootNavOptions, ) } + + RootNavState.VaultUnlockedForAuthRequest -> { + navController.navigateToVaultUnlockedGraph(rootNavOptions) + navController.navigateToLoginApproval( + fingerprint = null, + navOptions = rootNavOptions, + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 7c6451ccfe..42dd85274b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -83,6 +83,10 @@ class RootNavViewModel @Inject constructor( is SpecialCircumstance.ShareNewSend -> RootNavState.VaultUnlockedForNewSend + is SpecialCircumstance.PasswordlessRequest -> { + RootNavState.VaultUnlockedForAuthRequest + } + null -> { RootNavState.VaultUnlocked( activeUserId = userState.activeAccount.userId, @@ -156,6 +160,12 @@ sealed class RootNavState : Parcelable { */ @Parcelize data object VaultUnlockedForNewSend : RootNavState() + + /** + * App should show the auth confirmation screen for an unlocked user. + */ + @Parcelize + data object VaultUnlockedForAuthRequest : RootNavState() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt index 94081c90b1..17aeba6d3b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt @@ -4,20 +4,22 @@ import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions private const val FINGERPRINT: String = "fingerprint" private const val LOGIN_APPROVAL_PREFIX = "login_approval" -private const val LOGIN_APPROVAL_ROUTE = "$LOGIN_APPROVAL_PREFIX/{$FINGERPRINT}" +private const val LOGIN_APPROVAL_ROUTE = "$LOGIN_APPROVAL_PREFIX?$FINGERPRINT={$FINGERPRINT}" /** * Class to retrieve login approval arguments from the [SavedStateHandle]. */ @OmitFromCoverage -data class LoginApprovalArgs(val fingerprint: String) { +data class LoginApprovalArgs(val fingerprint: String?) { constructor(savedStateHandle: SavedStateHandle) : this( - checkNotNull(savedStateHandle[FINGERPRINT]) as String, + fingerprint = savedStateHandle.get(FINGERPRINT), ) } @@ -29,6 +31,13 @@ fun NavGraphBuilder.loginApprovalDestination( ) { composableWithSlideTransitions( route = LOGIN_APPROVAL_ROUTE, + arguments = listOf( + navArgument(FINGERPRINT) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), ) { LoginApprovalScreen( onNavigateBack = onNavigateBack, @@ -40,8 +49,8 @@ fun NavGraphBuilder.loginApprovalDestination( * Navigate to the Login Approval screen. */ fun NavController.navigateToLoginApproval( - fingerprint: String, + fingerprint: String?, navOptions: NavOptions? = null, ) { - navigate("$LOGIN_APPROVAL_PREFIX/$fingerprint", navOptions) + navigate("$LOGIN_APPROVAL_PREFIX?$FINGERPRINT=$fingerprint", navOptions) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt index a636282aeb..9a73bc2212 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt @@ -29,7 +29,7 @@ class LoginApprovalViewModel @Inject constructor( ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: LoginApprovalState( - fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint, + fingerprint = requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint), masterPasswordHash = null, publicKey = "", requestId = "", diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index ab4dcc23f0..262745b3bc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -6,6 +6,7 @@ import app.cash.turbine.test import com.bitwarden.core.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem @@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl +import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment @@ -131,6 +133,7 @@ class MainViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val mockIntent = mockk() val shareData = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData @@ -155,6 +158,7 @@ class MainViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val mockIntent = mockk() val autofillSelectionData = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData every { intentManager.getShareDataFromIntent(mockIntent) } returns null @@ -179,6 +183,7 @@ class MainViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val mockIntent = mockk() val autofillSaveItem = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem every { mockIntent.getAutofillSelectionDataOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null @@ -196,12 +201,40 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with a passwordless request data should set the special circumstance to PasswordlessRequest`() { + val viewModel = createViewModel() + val mockIntent = mockk() + val passwordlessRequestData = mockk() + every { + mockIntent.getPasswordlessRequestDataIntentOrNull() + } returns passwordlessRequestData + every { mockIntent.getAutofillSaveItemOrNull() } returns null + every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.PasswordlessRequest( + passwordlessRequestData = passwordlessRequestData, + shouldFinishWhenComplete = true, + ), + specialCircumstanceManager.specialCircumstance, + ) + } + @Suppress("MaxLineLength") @Test fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() { val viewModel = createViewModel() val mockIntent = mockk() val shareData = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData @@ -226,6 +259,7 @@ class MainViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val mockIntent = mockk() val autofillSelectionData = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData every { intentManager.getShareDataFromIntent(mockIntent) } returns null @@ -250,6 +284,7 @@ class MainViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val mockIntent = mockk() val autofillSaveItem = mockk() + every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem every { mockIntent.getAutofillSelectionDataOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null @@ -267,6 +302,33 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveNewIntent with a passwordless auth request data should set the special circumstance to PasswordlessRequest`() { + val viewModel = createViewModel() + val mockIntent = mockk() + val passwordlessRequestData = mockk() + every { + mockIntent.getPasswordlessRequestDataIntentOrNull() + } returns passwordlessRequestData + every { mockIntent.getAutofillSaveItemOrNull() } returns null + every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + + viewModel.trySendAction( + MainAction.ReceiveNewIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.PasswordlessRequest( + passwordlessRequestData = passwordlessRequestData, + shouldFinishWhenComplete = false, + ), + specialCircumstanceManager.specialCircumstance, + ) + } + @Suppress("MaxLineLength") @Test fun `changes in the allowed screen capture value should result in emissions of ScreenCaptureSettingChange `() =