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 `() =