mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 22:31:17 -05:00
Compare commits
23 Commits
renovate/s
...
PM-33982/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446f375a39 | ||
|
|
dad449efe3 | ||
|
|
113e157214 | ||
|
|
08fe476d2d | ||
|
|
c2e64cd75a | ||
|
|
07d72b6805 | ||
|
|
bd621e88b7 | ||
|
|
ca76823b2f | ||
|
|
1b6851163f | ||
|
|
2e7148368e | ||
|
|
5c22ee8f31 | ||
|
|
5b731bb38d | ||
|
|
2d4a5361da | ||
|
|
8a96228ac8 | ||
|
|
64e2e985e9 | ||
|
|
7ef963684e | ||
|
|
721cd2f7f4 | ||
|
|
91f3e2eca3 | ||
|
|
360340ba87 | ||
|
|
9396a9f68d | ||
|
|
25bc0d143b | ||
|
|
697d70f7f7 | ||
|
|
263de092bd |
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
@@ -357,6 +358,11 @@ interface AuthRepository :
|
||||
*/
|
||||
fun setCookieCallbackResult(result: CookieCallbackResult)
|
||||
|
||||
/**
|
||||
* Retrieves all devices registered to the current user.
|
||||
*/
|
||||
suspend fun getDevices(): GetDevicesResult
|
||||
|
||||
/**
|
||||
* Get a [Boolean] indicating whether this is a known device.
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
@@ -106,6 +107,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.privateKey
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
@@ -1406,6 +1408,20 @@ class AuthRepositoryImpl(
|
||||
mutableCookieCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override suspend fun getDevices(): GetDevicesResult =
|
||||
devicesService
|
||||
.getDevices()
|
||||
.fold(
|
||||
onFailure = { GetDevicesResult.Error },
|
||||
onSuccess = { response ->
|
||||
GetDevicesResult.Success(
|
||||
devices = response.devices.map { json ->
|
||||
json.toDeviceInfo(currentDeviceIdentifier = authDiskSource.uniqueAppId)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
||||
devicesService
|
||||
.getIsKnownDevice(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Domain model for a device registered to the current user.
|
||||
*
|
||||
* @property id The unique identifier of the device.
|
||||
* @property name The name of the device.
|
||||
* @property identifier The unique device install identifier of the device.
|
||||
* @property type The type of the device.
|
||||
* @property isTrusted Whether this device is trusted.
|
||||
* @property creationDate The date and time on which this device was created.
|
||||
* @property lastActivityDate The date and time of the device's last activity, if available.
|
||||
* @property pendingAuthRequest The pending auth request for this device, if any.
|
||||
* @property isCurrentDevice If this is the current device being used.
|
||||
*/
|
||||
@Parcelize
|
||||
data class DeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val identifier: String,
|
||||
val type: DeviceType,
|
||||
val isTrusted: Boolean,
|
||||
val creationDate: Instant,
|
||||
val lastActivityDate: Instant?,
|
||||
val pendingAuthRequest: DevicePendingAuthRequest?,
|
||||
val isCurrentDevice: Boolean,
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Domain model for a pending auth request associated with a device.
|
||||
*
|
||||
* @property id The unique identifier of the pending auth request.
|
||||
* @property creationDate The date and time on which this auth request was created.
|
||||
*/
|
||||
@Parcelize
|
||||
data class DevicePendingAuthRequest(
|
||||
val id: String,
|
||||
val creationDate: Instant,
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of retrieving all devices registered to the current user.
|
||||
*/
|
||||
sealed class GetDevicesResult {
|
||||
/**
|
||||
* Contains the list of [DeviceInfo] for the current user's registered devices.
|
||||
*/
|
||||
data class Success(val devices: List<DeviceInfo>) : GetDevicesResult()
|
||||
|
||||
/**
|
||||
* There was an error retrieving the devices.
|
||||
*/
|
||||
data object Error : GetDevicesResult()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.network.model.DeviceResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DevicePendingAuthRequest
|
||||
|
||||
/**
|
||||
* Maps the given [DeviceResponseJson] to a [DeviceInfo].
|
||||
*/
|
||||
fun DeviceResponseJson.toDeviceInfo(currentDeviceIdentifier: String): DeviceInfo =
|
||||
DeviceInfo(
|
||||
id = id,
|
||||
name = name,
|
||||
identifier = identifier,
|
||||
type = type,
|
||||
isTrusted = isTrusted,
|
||||
creationDate = creationDate,
|
||||
lastActivityDate = lastActivityDate,
|
||||
pendingAuthRequest = devicePendingAuthRequest?.let {
|
||||
DevicePendingAuthRequest(
|
||||
id = it.id,
|
||||
creationDate = it.creationDate,
|
||||
)
|
||||
},
|
||||
isCurrentDevice = identifier == currentDeviceIdentifier,
|
||||
)
|
||||
@@ -120,6 +120,7 @@ fun NavGraphBuilder.settingsGraph(
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
onNavigateToImportItems: () -> Unit,
|
||||
onNavigateToAboutPrivilegedApps: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
) {
|
||||
navigation<SettingsGraphRoute>(
|
||||
startDestination = SettingsRoute.Standard,
|
||||
@@ -147,6 +148,7 @@ fun NavGraphBuilder.settingsGraph(
|
||||
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
onNavigateToPendingRequests = onNavigateToPendingRequests,
|
||||
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
|
||||
onNavigateToManageDevices = onNavigateToManageDevices,
|
||||
)
|
||||
appearanceDestination(
|
||||
isPreAuth = false,
|
||||
|
||||
@@ -20,6 +20,7 @@ fun NavGraphBuilder.accountSecurityDestination(
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToPendingRequests: () -> Unit,
|
||||
onNavigateToSetupUnlockScreen: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<AccountSecurityRoute> {
|
||||
AccountSecurityScreen(
|
||||
@@ -27,6 +28,7 @@ fun NavGraphBuilder.accountSecurityDestination(
|
||||
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
onNavigateToPendingRequests = onNavigateToPendingRequests,
|
||||
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
|
||||
onNavigateToManageDevices = onNavigateToManageDevices,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ fun AccountSecurityScreen(
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToPendingRequests: () -> Unit,
|
||||
onNavigateToSetupUnlockScreen: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
viewModel: AccountSecurityViewModel = hiltViewModel(),
|
||||
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
@@ -118,6 +119,8 @@ fun AccountSecurityScreen(
|
||||
intentManager.launchUri(event.url.toUri())
|
||||
}
|
||||
|
||||
is AccountSecurityEvent.NavigateToManageDevices -> onNavigateToManageDevices()
|
||||
|
||||
is AccountSecurityEvent.ShowBiometricsPrompt -> {
|
||||
showBiometricsPrompt = true
|
||||
biometricsManager.promptBiometrics(
|
||||
@@ -192,32 +195,36 @@ fun AccountSecurityScreen(
|
||||
)
|
||||
}
|
||||
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.approve_login_requests),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
BitwardenTextRow(
|
||||
text = stringResource(id = BitwardenString.pending_log_in_requests),
|
||||
onClick = {
|
||||
viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick)
|
||||
},
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag("PendingLogInRequestsLabel")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
if (!state.isManageDevicesEnabled) {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.approve_login_requests),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
BitwardenTextRow(
|
||||
text = stringResource(id = BitwardenString.pending_log_in_requests),
|
||||
onClick = {
|
||||
viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick)
|
||||
},
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag("PendingLogInRequestsLabel")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
val biometricSupportStatus = biometricsManager.biometricSupportStatus
|
||||
if (biometricSupportStatus != BiometricSupportStatus.NOT_SUPPORTED ||
|
||||
!state.removeUnlockWithPinPolicyEnabled ||
|
||||
state.isUnlockWithPinEnabled
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (!state.isManageDevicesEnabled) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.unlock_options),
|
||||
modifier = Modifier
|
||||
@@ -335,12 +342,29 @@ fun AccountSecurityScreen(
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
if (state.isManageDevicesEnabled) {
|
||||
BitwardenTextRow(
|
||||
text = stringResource(id = BitwardenString.manage_devices),
|
||||
onClick = {
|
||||
viewModel.trySendAction(AccountSecurityAction.ManageDevicesClick)
|
||||
},
|
||||
cardStyle = CardStyle.Top(),
|
||||
modifier = Modifier
|
||||
.testTag("ManageDevicesLabel")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
BitwardenTextRow(
|
||||
text = stringResource(id = BitwardenString.account_fingerprint_phrase),
|
||||
onClick = {
|
||||
viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick)
|
||||
},
|
||||
cardStyle = CardStyle.Top(),
|
||||
cardStyle = if (state.isManageDevicesEnabled) {
|
||||
CardStyle.Middle()
|
||||
} else {
|
||||
CardStyle.Top()
|
||||
},
|
||||
modifier = Modifier
|
||||
.testTag("AccountFingerprintPhraseLabel")
|
||||
.standardHorizontalMargin()
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
@@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
@@ -51,6 +53,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
policyManager: PolicyManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
|
||||
@@ -65,6 +68,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
authRepository.isBiometricIntegrityValid(userId = userId),
|
||||
isUnlockWithPasswordEnabled = userState.activeAccount.hasMasterPassword,
|
||||
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
|
||||
isManageDevicesEnabled = featureFlagManager.getFeatureFlag(FlagKey.ManageDevices),
|
||||
shouldShowEnableAuthenticatorSync = isBuildVersionAtLeast(Build.VERSION_CODES.S),
|
||||
userId = userId,
|
||||
vaultTimeout = settingsRepository.vaultTimeout,
|
||||
@@ -139,6 +143,12 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
featureFlagManager
|
||||
.getFeatureFlagFlow(FlagKey.ManageDevices)
|
||||
.map { AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
viewModelScope.launch {
|
||||
trySendAction(
|
||||
AccountSecurityAction.Internal.FingerprintResultReceive(
|
||||
@@ -160,6 +170,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick()
|
||||
AccountSecurityAction.LockNowClick -> handleLockNowClick()
|
||||
AccountSecurityAction.LogoutClick -> handleLogoutClick()
|
||||
AccountSecurityAction.ManageDevicesClick -> handleManageDevicesClick()
|
||||
AccountSecurityAction.PendingLoginRequestsClick -> handlePendingLoginRequestsClick()
|
||||
is AccountSecurityAction.VaultTimeoutTypeSelect -> handleVaultTimeoutTypeSelect(action)
|
||||
is AccountSecurityAction.CustomVaultTimeoutSelect -> handleCustomVaultTimeoutSelect(action)
|
||||
@@ -353,6 +364,10 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
dismissUnlockNotificationBadge()
|
||||
}
|
||||
|
||||
private fun handleManageDevicesClick() {
|
||||
sendEvent(AccountSecurityEvent.NavigateToManageDevices)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: AccountSecurityAction.Internal) {
|
||||
when (action) {
|
||||
is AccountSecurityAction.Internal.BiometricsKeyResultReceive -> {
|
||||
@@ -382,6 +397,10 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
is AccountSecurityAction.Internal.RemovePinPolicyUpdateReceive -> {
|
||||
handleRemovePinPolicyUpdate(action)
|
||||
}
|
||||
|
||||
is AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive -> {
|
||||
handleManageDevicesFlagUpdateReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +513,12 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
settingsRepository.vaultTimeoutAction = vaultTimeoutAction
|
||||
}
|
||||
|
||||
private fun handleManageDevicesFlagUpdateReceive(
|
||||
action: AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(isManageDevicesEnabled = action.isEnabled) }
|
||||
}
|
||||
|
||||
private fun dismissUnlockNotificationBadge() {
|
||||
if (!state.shouldShowUnlockActionCard) return
|
||||
firstTimeActionManager.storeShowUnlockSettingBadge(
|
||||
@@ -513,6 +538,7 @@ data class AccountSecurityState(
|
||||
val isUnlockWithBiometricsEnabled: Boolean,
|
||||
val isUnlockWithPasswordEnabled: Boolean,
|
||||
val isUnlockWithPinEnabled: Boolean,
|
||||
val isManageDevicesEnabled: Boolean,
|
||||
val shouldShowEnableAuthenticatorSync: Boolean,
|
||||
val userId: String,
|
||||
val vaultTimeout: VaultTimeout,
|
||||
@@ -692,6 +718,11 @@ sealed class AccountSecurityEvent {
|
||||
* Navigate to the setup unlock screen.
|
||||
*/
|
||||
data object NavigateToSetupUnlockScreen : AccountSecurityEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Manage Devices screen.
|
||||
*/
|
||||
data object NavigateToManageDevices : AccountSecurityEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -756,6 +787,11 @@ sealed class AccountSecurityAction {
|
||||
*/
|
||||
data object LogoutClick : AccountSecurityAction()
|
||||
|
||||
/**
|
||||
* User clicked manage devices.
|
||||
*/
|
||||
data object ManageDevicesClick : AccountSecurityAction()
|
||||
|
||||
/**
|
||||
* User clicked pending login requests.
|
||||
*/
|
||||
@@ -872,5 +908,12 @@ sealed class AccountSecurityAction {
|
||||
data class PinProtectedLockUpdate(
|
||||
val isPinProtected: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The manage devices feature flag has been updated.
|
||||
*/
|
||||
data class ManageDevicesFlagUpdateReceive(
|
||||
val isEnabled: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the manage devices screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object ManageDevicesRoute
|
||||
|
||||
/**
|
||||
* Add manage devices destinations to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.manageDevicesDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ManageDevicesRoute> {
|
||||
ManageDevicesScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToLoginApproval = onNavigateToLoginApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Manage Devices screen.
|
||||
*/
|
||||
fun NavController.navigateToManageDevices(
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(route = ManageDevicesRoute, navOptions = navOptions)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.LifecycleEventEffect
|
||||
import com.bitwarden.ui.platform.base.util.annotatedStringResource
|
||||
import com.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.bitwarden.ui.platform.base.util.mirrorIfRtl
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.bottomsheet.BitwardenModalBottomSheet
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.scaffold.model.rememberBitwardenPullToRefreshState
|
||||
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
|
||||
|
||||
/**
|
||||
* Displays the Manage Devices screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ManageDevicesScreen(
|
||||
viewModel: ManageDevicesViewModel = hiltViewModel(),
|
||||
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val pullToRefreshState = rememberBitwardenPullToRefreshState(
|
||||
isEnabled = state.isPullToRefreshEnabled,
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = { viewModel.trySendAction(ManageDevicesAction.RefreshPull) },
|
||||
)
|
||||
val snackbarHostState = rememberBitwardenSnackbarHostState()
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ManageDevicesEvent.NavigateBack -> onNavigateBack()
|
||||
is ManageDevicesEvent.NavigateToLoginApproval -> {
|
||||
onNavigateToLoginApproval(event.fingerprint)
|
||||
}
|
||||
|
||||
is ManageDevicesEvent.ShowSnackbar ->
|
||||
snackbarHostState.showSnackbar(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleEventEffect { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
viewModel.trySendAction(ManageDevicesAction.LifecycleResume)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val hideBottomSheet = state.hideBottomSheet ||
|
||||
permissionsManager.checkPermission(Manifest.permission.POST_NOTIFICATIONS) ||
|
||||
permissionsManager.shouldShowRequestPermissionRationale(
|
||||
permission = Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
BitwardenModalBottomSheet(
|
||||
showBottomSheet = !hideBottomSheet,
|
||||
sheetTitle = stringResource(BitwardenString.enable_notifications),
|
||||
onDismiss = { viewModel.trySendAction(ManageDevicesAction.HideBottomSheet) },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
) { animatedOnDismiss ->
|
||||
ManageDevicesBottomSheetContent(
|
||||
permissionsManager = permissionsManager,
|
||||
onDismiss = animatedOnDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.manage_devices),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = BitwardenString.close),
|
||||
onNavigationIconClick = {
|
||||
viewModel.trySendAction(ManageDevicesAction.CloseClick)
|
||||
},
|
||||
)
|
||||
},
|
||||
pullToRefreshState = pullToRefreshState,
|
||||
snackbarHost = {
|
||||
BitwardenSnackbarHost(bitwardenHostState = snackbarHostState)
|
||||
},
|
||||
) {
|
||||
when (val viewState = state.viewState) {
|
||||
is ManageDevicesState.ViewState.Content -> {
|
||||
ManageDevicesContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState,
|
||||
onNavigateToLoginApproval = {
|
||||
viewModel.trySendAction(ManageDevicesAction.PendingRequestRowClick(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ManageDevicesState.ViewState.Error -> BitwardenErrorContent(
|
||||
message = stringResource(
|
||||
id = BitwardenString.generic_error_message,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
ManageDevicesState.ViewState.Loading -> BitwardenLoadingContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the list content for the Manage Devices screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManageDevicesContent(
|
||||
state: ManageDevicesState.ViewState.Content,
|
||||
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
}
|
||||
itemsIndexed(state.items) { index, item ->
|
||||
when (item.status) {
|
||||
DeviceSessionStatus.Pending -> item.fingerprintPhrase?.let {
|
||||
PendingRequestItem(
|
||||
fingerprintPhrase = item.fingerprintPhrase,
|
||||
platform = item.typeName(),
|
||||
firstLoginDate = item.firstLoginDate,
|
||||
isTrusted = item.isTrusted,
|
||||
onNavigateToLoginApproval = onNavigateToLoginApproval,
|
||||
cardStyle = state.items.toListItemCardStyle(
|
||||
index = index,
|
||||
dividerPadding = 0.dp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.testTag("LoginRequestCell")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
DeviceSessionStatus.None,
|
||||
DeviceSessionStatus.Current,
|
||||
-> {
|
||||
SessionItem(
|
||||
platform = item.typeName(),
|
||||
firstLoginDate = item.firstLoginDate,
|
||||
lastActivityLabel = item.lastActivityLabel,
|
||||
status = item.status,
|
||||
isTrusted = item.isTrusted,
|
||||
cardStyle = state.items.toListItemCardStyle(
|
||||
index = index,
|
||||
dividerPadding = 0.dp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.testTag("CurrentItemCell")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a pending request item to display in the list.
|
||||
*/
|
||||
@Composable
|
||||
private fun PendingRequestItem(
|
||||
fingerprintPhrase: String,
|
||||
platform: String,
|
||||
firstLoginDate: String,
|
||||
isTrusted: Boolean,
|
||||
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
|
||||
cardStyle: CardStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
onClick = { onNavigateToLoginApproval(fingerprintPhrase) },
|
||||
paddingHorizontal = 16.dp,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = platform,
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
if (isTrusted) {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.trusted),
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(height = 8.dp))
|
||||
DeviceStatusIndicatorRow(
|
||||
label = stringResource(id = BitwardenString.pending_request),
|
||||
color = BitwardenTheme.colorScheme.status.weak2,
|
||||
)
|
||||
DeviceInfoAnnotatedLabel(
|
||||
id = BitwardenString.first_login_date,
|
||||
arg = firstLoginDate,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_right),
|
||||
contentDescription = null,
|
||||
tint = BitwardenTheme.colorScheme.icon.primary,
|
||||
modifier = Modifier
|
||||
.mirrorIfRtl()
|
||||
.size(size = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a registered session item to display in the list.
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionItem(
|
||||
platform: String,
|
||||
firstLoginDate: String,
|
||||
lastActivityLabel: Text?,
|
||||
status: DeviceSessionStatus,
|
||||
isTrusted: Boolean,
|
||||
cardStyle: CardStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
paddingHorizontal = 16.dp,
|
||||
),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = platform,
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
if (isTrusted) {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.trusted),
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(height = 8.dp))
|
||||
if (status == DeviceSessionStatus.Current) {
|
||||
DeviceStatusIndicatorRow(
|
||||
label = stringResource(id = BitwardenString.current_session),
|
||||
color = BitwardenTheme.colorScheme.status.strong,
|
||||
)
|
||||
} else {
|
||||
lastActivityLabel?.let {
|
||||
DeviceInfoAnnotatedLabel(
|
||||
id = BitwardenString.recently_active,
|
||||
arg = it(),
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceInfoAnnotatedLabel(
|
||||
id = BitwardenString.first_login_date,
|
||||
arg = firstLoginDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a colored dot followed by [label] in a horizontal row, used to indicate device status.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceStatusIndicatorRow(
|
||||
label: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an annotated string resource with [arg] substituted in, using secondary text color and
|
||||
* a bold emphasis style for the dynamic portion.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceInfoAnnotatedLabel(
|
||||
@StringRes id: Int,
|
||||
arg: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = annotatedStringResource(
|
||||
id = id,
|
||||
args = arrayOf(arg),
|
||||
emphasisHighlightStyle = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
fontSize = BitwardenTheme.typography.bodySmall.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
style = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
fontSize = BitwardenTheme.typography.bodySmall.fontSize,
|
||||
),
|
||||
),
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageDevicesBottomSheetContent(
|
||||
permissionsManager: PermissionsManager,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val notificationPermissionLauncher = permissionsManager.getLauncher {
|
||||
onDismiss()
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_2fa),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(size = 132.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.log_in_quickly_and_easily_across_devices),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
@Suppress("MaxLineLength")
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.bitwarden_can_notify_you_each_time_you_receive_a_new_login_request_from_another_device,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.enable_notifications),
|
||||
onClick = {
|
||||
@SuppressLint("InlinedApi")
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.skip_for_now),
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.core.util.isOverFiveMinutesOld
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.readableDeviceTypeName
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.toLastActivityLabel
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.format.FormatStyle
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* View model for the Manage Devices screen.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@HiltViewModel
|
||||
class ManageDevicesViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
private val authRepository: AuthRepository,
|
||||
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
settingsRepository: SettingsRepository,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<ManageDevicesState, ManageDevicesEvent, ManageDevicesAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ManageDevicesState(
|
||||
authRequests = persistentListOf(),
|
||||
devices = persistentListOf(),
|
||||
viewState = ManageDevicesState.ViewState.Loading,
|
||||
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = buildInfoManager.isFdroid,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
),
|
||||
) {
|
||||
private var authJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
updateAuthRequestList()
|
||||
fetchAllDevices()
|
||||
settingsRepository
|
||||
.getPullToRefreshEnabledFlow()
|
||||
.map { ManageDevicesAction.Internal.PullToRefreshEnableReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
snackbarRelayManager
|
||||
.getSnackbarDataFlow(SnackbarRelay.LOGIN_APPROVAL)
|
||||
.map { ManageDevicesAction.Internal.SnackbarDataReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ManageDevicesAction) {
|
||||
when (action) {
|
||||
ManageDevicesAction.CloseClick -> handleCloseClicked()
|
||||
ManageDevicesAction.HideBottomSheet -> handleHideBottomSheet()
|
||||
ManageDevicesAction.LifecycleResume -> handleOnLifecycleResumed()
|
||||
ManageDevicesAction.RefreshPull -> handleRefreshPull()
|
||||
is ManageDevicesAction.PendingRequestRowClick -> {
|
||||
handlePendingRequestRowClicked(action)
|
||||
}
|
||||
|
||||
is ManageDevicesAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClicked() {
|
||||
sendEvent(ManageDevicesEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleHideBottomSheet() {
|
||||
mutableStateFlow.update { it.copy(internalHideBottomSheet = true) }
|
||||
}
|
||||
|
||||
private fun handleOnLifecycleResumed() {
|
||||
updateAuthRequestList()
|
||||
}
|
||||
|
||||
private fun handleRefreshPull() {
|
||||
val shouldRefetchDevices = !state.devicesLoaded
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isRefreshing = true,
|
||||
authRequestsLoaded = false,
|
||||
)
|
||||
}
|
||||
updateAuthRequestList()
|
||||
if (shouldRefetchDevices) {
|
||||
fetchAllDevices()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePendingRequestRowClicked(
|
||||
action: ManageDevicesAction.PendingRequestRowClick,
|
||||
) {
|
||||
sendEvent(ManageDevicesEvent.NavigateToLoginApproval(action.fingerprint))
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: ManageDevicesAction.Internal) {
|
||||
when (action) {
|
||||
is ManageDevicesAction.Internal.PullToRefreshEnableReceive -> {
|
||||
handlePullToRefreshEnableReceive(action)
|
||||
}
|
||||
|
||||
is ManageDevicesAction.Internal.SnackbarDataReceive -> {
|
||||
handleSnackbarDataReceive(action)
|
||||
}
|
||||
|
||||
is ManageDevicesAction.Internal.GetDevicesResultReceive -> {
|
||||
handleGetDevicesResultReceived(action)
|
||||
}
|
||||
|
||||
is ManageDevicesAction.Internal.AuthRequestsResultReceive -> {
|
||||
handleAuthRequestsResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePullToRefreshEnableReceive(
|
||||
action: ManageDevicesAction.Internal.PullToRefreshEnableReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSnackbarDataReceive(
|
||||
action: ManageDevicesAction.Internal.SnackbarDataReceive,
|
||||
) {
|
||||
sendEvent(ManageDevicesEvent.ShowSnackbar(action.data))
|
||||
}
|
||||
|
||||
private fun updateAuthRequestList() {
|
||||
authJob.cancel()
|
||||
authJob = authRepository
|
||||
.getAuthRequestsWithUpdates()
|
||||
.map { ManageDevicesAction.Internal.AuthRequestsResultReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun fetchAllDevices() {
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
ManageDevicesAction.Internal.GetDevicesResultReceive(
|
||||
devicesResult = authRepository.getDevices(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAuthRequestsResultReceived(
|
||||
action: ManageDevicesAction.Internal.AuthRequestsResultReceive,
|
||||
) {
|
||||
val filteredRequests = when (val result = action.authRequestsUpdatesResult) {
|
||||
is AuthRequestsUpdatesResult.Update -> {
|
||||
result.authRequests.filterRespondedAndExpired(clock = clock)
|
||||
}
|
||||
|
||||
is AuthRequestsUpdatesResult.Error -> emptyList()
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
authRequests = filteredRequests.toImmutableList(),
|
||||
authRequestsLoaded = true,
|
||||
isRefreshing = if (state.devicesLoaded) false else it.isRefreshing,
|
||||
)
|
||||
}
|
||||
if (state.devicesLoaded) {
|
||||
updateContentWithCurrentData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetDevicesResultReceived(
|
||||
action: ManageDevicesAction.Internal.GetDevicesResultReceive,
|
||||
) {
|
||||
val devicesResult = action.devicesResult as? GetDevicesResult.Success
|
||||
?: run {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = ManageDevicesState.ViewState.Error, isRefreshing = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
devices = devicesResult.devices.toImmutableList(),
|
||||
devicesLoaded = true,
|
||||
isRefreshing = if (state.authRequestsLoaded) false else it.isRefreshing,
|
||||
)
|
||||
}
|
||||
if (state.authRequestsLoaded) {
|
||||
updateContentWithCurrentData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContentWithCurrentData() {
|
||||
val authRequestMap = state.authRequests.associateBy { it.id }
|
||||
val items = state.devices
|
||||
.sortedWith(
|
||||
compareBy<DeviceInfo> { device ->
|
||||
val matchingRequest = device.pendingAuthRequest?.let { authRequestMap[it.id] }
|
||||
when {
|
||||
device.isCurrentDevice -> 0
|
||||
matchingRequest != null -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
.thenByDescending { it.lastActivityDate }
|
||||
.thenByDescending { it.creationDate },
|
||||
)
|
||||
.map { device ->
|
||||
val matchingRequest = device.pendingAuthRequest?.let { authRequestMap[it.id] }
|
||||
val status = when {
|
||||
device.isCurrentDevice -> DeviceSessionStatus.Current
|
||||
matchingRequest != null -> DeviceSessionStatus.Pending
|
||||
else -> DeviceSessionStatus.None
|
||||
}
|
||||
ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = device.id,
|
||||
name = device.name,
|
||||
typeName = device.type.readableDeviceTypeName,
|
||||
isTrusted = device.isTrusted,
|
||||
firstLoginDate = device.creationDate.toFormattedDateTimeStyle(
|
||||
dateStyle = FormatStyle.MEDIUM,
|
||||
timeStyle = FormatStyle.MEDIUM,
|
||||
clock = clock,
|
||||
),
|
||||
lastActivityLabel = device.lastActivityDate?.toLastActivityLabel(
|
||||
clock = clock,
|
||||
),
|
||||
status = status,
|
||||
fingerprintPhrase = matchingRequest?.fingerprint,
|
||||
)
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = ManageDevicesState.ViewState.Content(items = items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state for the Manage Devices screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ManageDevicesState(
|
||||
val authRequests: ImmutableList<AuthRequest>,
|
||||
val devices: ImmutableList<DeviceInfo>,
|
||||
val viewState: ViewState,
|
||||
private val isPullToRefreshSettingEnabled: Boolean,
|
||||
val isRefreshing: Boolean,
|
||||
private val internalHideBottomSheet: Boolean,
|
||||
private val isFdroid: Boolean,
|
||||
val devicesLoaded: Boolean,
|
||||
val authRequestsLoaded: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Indicates that the bottom sheet should be hidden.
|
||||
*/
|
||||
@get:ChecksSdkIntAtLeast(parameter = Build.VERSION_CODES.TIRAMISU)
|
||||
val hideBottomSheet: Boolean
|
||||
get() = internalHideBottomSheet &&
|
||||
!isFdroid &&
|
||||
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
|
||||
|
||||
/**
|
||||
* Indicates that the pull-to-refresh should be enabled in the UI.
|
||||
*/
|
||||
val isPullToRefreshEnabled: Boolean
|
||||
get() = isPullToRefreshSettingEnabled && viewState.isPullToRefreshEnabled
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the [ManageDevicesScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
sealed class ViewState : Parcelable {
|
||||
/**
|
||||
* Indicates the pull-to-refresh feature should be available during the current state.
|
||||
*/
|
||||
abstract val isPullToRefreshEnabled: Boolean
|
||||
|
||||
/**
|
||||
* Content state for the [ManageDevicesScreen] listing device items.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Content(
|
||||
val items: List<DeviceItem>,
|
||||
) : ViewState() {
|
||||
override val isPullToRefreshEnabled: Boolean get() = true
|
||||
|
||||
/**
|
||||
* Models the data for a registered device, optionally with a pending auth request.
|
||||
*/
|
||||
@Parcelize
|
||||
data class DeviceItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val typeName: Text,
|
||||
val isTrusted: Boolean,
|
||||
val firstLoginDate: String,
|
||||
val lastActivityLabel: Text?,
|
||||
val status: DeviceSessionStatus,
|
||||
val fingerprintPhrase: String?,
|
||||
) : Parcelable
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a state where the [ManageDevicesScreen] is unable to display data due to an
|
||||
* error retrieving it.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Error : ViewState() {
|
||||
override val isPullToRefreshEnabled: Boolean get() = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state for the [ManageDevicesScreen], signifying that the content is being
|
||||
* processed.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : ViewState() {
|
||||
override val isPullToRefreshEnabled: Boolean get() = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the Manage Devices screen.
|
||||
*/
|
||||
sealed class ManageDevicesEvent {
|
||||
/**
|
||||
* Navigates back.
|
||||
*/
|
||||
data object NavigateBack : ManageDevicesEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the Login Approval screen with the given request ID.
|
||||
*/
|
||||
data class NavigateToLoginApproval(
|
||||
val fingerprint: String,
|
||||
) : ManageDevicesEvent()
|
||||
|
||||
/**
|
||||
* Show a snackbar to the user.
|
||||
*/
|
||||
data class ShowSnackbar(
|
||||
val data: BitwardenSnackbarData,
|
||||
) : ManageDevicesEvent(), BackgroundEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the Manage Devices screen.
|
||||
*/
|
||||
sealed class ManageDevicesAction {
|
||||
|
||||
/**
|
||||
* The user has clicked the close button.
|
||||
*/
|
||||
data object CloseClick : ManageDevicesAction()
|
||||
|
||||
/**
|
||||
* The user has dismissed the bottom sheet.
|
||||
*/
|
||||
data object HideBottomSheet : ManageDevicesAction()
|
||||
|
||||
/**
|
||||
* The screen has been re-opened and should be updated.
|
||||
*/
|
||||
data object LifecycleResume : ManageDevicesAction()
|
||||
|
||||
/**
|
||||
* The user has clicked one of the pending request rows.
|
||||
*/
|
||||
data class PendingRequestRowClick(
|
||||
val fingerprint: String,
|
||||
) : ManageDevicesAction()
|
||||
|
||||
/**
|
||||
* User has triggered a pull to refresh.
|
||||
*/
|
||||
data object RefreshPull : ManageDevicesAction()
|
||||
|
||||
/**
|
||||
* Models actions sent by the view model itself.
|
||||
*/
|
||||
sealed class Internal : ManageDevicesAction() {
|
||||
/**
|
||||
* Indicates that the pull to refresh feature toggle has changed.
|
||||
*/
|
||||
data class PullToRefreshEnableReceive(
|
||||
val isPullToRefreshEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that a snackbar data was received.
|
||||
*/
|
||||
data class SnackbarDataReceive(
|
||||
val data: BitwardenSnackbarData,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the get devices has been received.
|
||||
*/
|
||||
data class GetDevicesResultReceive(
|
||||
val devicesResult: GetDevicesResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that an auth requests update has been received.
|
||||
*/
|
||||
data class AuthRequestsResultReceive(
|
||||
val authRequestsUpdatesResult: AuthRequestsUpdatesResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the session status of a registered device.
|
||||
*/
|
||||
enum class DeviceSessionStatus {
|
||||
Current,
|
||||
Pending,
|
||||
None,
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out [AuthRequest]s that match one of the following criteria:
|
||||
* * The request has been approved.
|
||||
* * The request has been declined (indicated by it not being approved & having a responseDate).
|
||||
* * The request has expired (it is at least 5 minutes old).
|
||||
*/
|
||||
private fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock) =
|
||||
filterNot { request ->
|
||||
request.requestApproved ||
|
||||
request.responseDate != null ||
|
||||
request.creationDate.isOverFiveMinutesOld(clock)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
|
||||
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Returns a localized string describing how recently this device was active,
|
||||
* or null if no activity date is available.
|
||||
*
|
||||
* Buckets are based on calendar days in the device's local timezone, matching
|
||||
* the web client behaviour. Using [java.time.LocalDate] comparison makes this DST-safe without
|
||||
* requiring rounding (unlike the JavaScript equivalent).
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun Instant.toLastActivityLabel(clock: Clock): Text {
|
||||
val nowDate = clock.instant().atZone(clock.zone).toLocalDate()
|
||||
val activityDate = this.atZone(clock.zone).toLocalDate()
|
||||
val daysAgo = ChronoUnit.DAYS.between(activityDate, nowDate)
|
||||
val resId = when {
|
||||
daysAgo <= 0 -> BitwardenString.today
|
||||
daysAgo < 7 -> BitwardenString.past_seven_days
|
||||
daysAgo < 14 -> BitwardenString.past_fourteen_days
|
||||
daysAgo < 30 -> BitwardenString.past_thirty_days
|
||||
else -> BitwardenString.over_thirty_days_ago
|
||||
}
|
||||
return resId.asText()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
|
||||
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
|
||||
/**
|
||||
* Converts a [DeviceType] to a human-readable display name.
|
||||
* Returns e.g. "Mobile - Android", "Extension - Chrome", "Desktop - Windows".
|
||||
*/
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
val DeviceType.readableDeviceTypeName: Text
|
||||
get() = when (this) {
|
||||
DeviceType.ANDROID -> BitwardenString.mobile_platform.asText("Android")
|
||||
DeviceType.IOS -> BitwardenString.mobile_platform.asText("iOS")
|
||||
DeviceType.CHROME_EXTENSION -> BitwardenString.extension_platform.asText("Chrome")
|
||||
DeviceType.FIREFOX_EXTENSION -> BitwardenString.extension_platform.asText("Firefox")
|
||||
DeviceType.OPERA_EXTENSION -> BitwardenString.extension_platform.asText("Opera")
|
||||
DeviceType.EDGE_EXTENSION -> BitwardenString.extension_platform.asText("Edge")
|
||||
DeviceType.WINDOWS_DESKTOP -> BitwardenString.desktop_platform.asText("Windows")
|
||||
DeviceType.MAC_OS_DESKTOP -> BitwardenString.desktop_platform.asText("MacOS")
|
||||
DeviceType.LINUX_DESKTOP -> BitwardenString.desktop_platform.asText("Linux")
|
||||
DeviceType.CHROME_BROWSER -> BitwardenString.web_platform.asText("Chrome")
|
||||
DeviceType.FIREFOX_BROWSER -> BitwardenString.web_platform.asText("Firefox")
|
||||
DeviceType.OPERA_BROWSER -> BitwardenString.web_platform.asText("Opera")
|
||||
DeviceType.EDGE_BROWSER -> BitwardenString.web_platform.asText("Edge")
|
||||
DeviceType.IE_BROWSER -> BitwardenString.web_platform.asText("IE")
|
||||
DeviceType.UNKNOWN_BROWSER -> BitwardenString.web_platform.asText("Unknown")
|
||||
DeviceType.ANDROID_AMAZON -> BitwardenString.mobile_platform.asText("Amazon")
|
||||
DeviceType.UWP -> BitwardenString.desktop_platform.asText("Windows UWP")
|
||||
DeviceType.SAFARI_BROWSER -> BitwardenString.web_platform.asText("Safari")
|
||||
DeviceType.VIVALDI_BROWSER -> BitwardenString.web_platform.asText("Vivaldi")
|
||||
DeviceType.VIVALDI_EXTENSION -> BitwardenString.extension_platform.asText("Vivaldi")
|
||||
DeviceType.SAFARI_EXTENSION -> BitwardenString.extension_platform.asText("Safari")
|
||||
DeviceType.SDK -> BitwardenString.sdk.asText()
|
||||
DeviceType.SERVER -> BitwardenString.server.asText()
|
||||
DeviceType.WINDOWS_CLI -> BitwardenString.cli_platform.asText("Windows")
|
||||
DeviceType.MAC_OS_CLI -> BitwardenString.cli_platform.asText("MacOS")
|
||||
DeviceType.LINUX_CLI -> BitwardenString.cli_platform.asText("Linux")
|
||||
DeviceType.DUCK_DUCK_GO_BROWSER -> BitwardenString.extension_platform.asText("DuckDuckGo")
|
||||
DeviceType.UNKNOWN -> BitwardenString.unknown_device.asText()
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteac
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.navigateToDeleteAccountConfirmation
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.manageDevicesDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.navigateToManageDevices
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.about.aboutPrivilegedAppsDestination
|
||||
@@ -120,6 +122,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
onNavigateToViewSend = { navController.navigateToViewSend(route = it) },
|
||||
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
|
||||
onNavigateToPendingRequests = { navController.navigateToPendingRequests() },
|
||||
onNavigateToManageDevices = { navController.navigateToManageDevices() },
|
||||
onNavigateToPasswordHistory = {
|
||||
navController.navigateToPasswordHistory(
|
||||
passwordHistoryMode = GeneratorPasswordHistoryMode.Default,
|
||||
@@ -171,6 +174,10 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToLoginApproval = { navController.navigateToLoginApproval(it) },
|
||||
)
|
||||
manageDevicesDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToLoginApproval = { navController.navigateToLoginApproval(it) },
|
||||
)
|
||||
vaultAddEditDestination(
|
||||
onNavigateToQrCodeScanScreen = {
|
||||
navController.navigateToQrCodeScanScreen()
|
||||
|
||||
@@ -52,6 +52,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit,
|
||||
onNavigateToAboutPrivilegedApps: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
onNavigateToPlan: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<VaultUnlockedNavbarRoute> {
|
||||
@@ -76,6 +77,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
|
||||
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
|
||||
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
|
||||
onNavigateToManageDevices = onNavigateToManageDevices,
|
||||
onNavigateToPlan = onNavigateToPlan,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ fun VaultUnlockedNavBarScreen(
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
|
||||
onNavigateToAboutPrivilegedApps: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
onNavigateToPlan: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
@@ -110,6 +111,7 @@ fun VaultUnlockedNavBarScreen(
|
||||
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
|
||||
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
|
||||
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
|
||||
onNavigateToManageDevices = onNavigateToManageDevices,
|
||||
onNavigateToPlan = onNavigateToPlan,
|
||||
)
|
||||
}
|
||||
@@ -146,6 +148,7 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
|
||||
onNavigateToAboutPrivilegedApps: () -> Unit,
|
||||
onNavigateToManageDevices: () -> Unit,
|
||||
onNavigateToPlan: () -> Unit,
|
||||
) {
|
||||
var shouldDimNavBar by rememberSaveable { mutableStateOf(value = false) }
|
||||
@@ -232,6 +235,7 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
|
||||
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
|
||||
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
|
||||
onNavigateToManageDevices = onNavigateToManageDevices,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.ConfigResponseJson
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.DeviceResponseJson
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import com.bitwarden.network.model.DevicesResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.IdentityTokenAuthModel
|
||||
import com.bitwarden.network.model.KdfJson
|
||||
@@ -98,7 +101,9 @@ import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorR
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
@@ -6874,6 +6879,58 @@ class AuthRepositoryTest {
|
||||
assertEquals(KnownDeviceResult.Success(isKnownDevice), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDevices should return Error when service returns failure`() = runTest {
|
||||
val error = Throwable("Fail!")
|
||||
coEvery { devicesService.getDevices() } returns error.asFailure()
|
||||
|
||||
val result = repository.getDevices()
|
||||
|
||||
coVerify(exactly = 1) { devicesService.getDevices() }
|
||||
assertEquals(GetDevicesResult.Error, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDevices should return Success when service returns success`() = runTest {
|
||||
val deviceJson = DeviceResponseJson(
|
||||
id = "deviceId",
|
||||
name = "Test Device",
|
||||
identifier = "deviceIdentifier",
|
||||
type = DeviceType.ANDROID,
|
||||
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
|
||||
lastActivityDate = null,
|
||||
isTrusted = false,
|
||||
encryptedUserKey = null,
|
||||
encryptedPublicKey = null,
|
||||
devicePendingAuthRequest = null,
|
||||
)
|
||||
val devicesResponse = DevicesResponseJson(devices = listOf(deviceJson))
|
||||
coEvery { devicesService.getDevices() } returns devicesResponse.asSuccess()
|
||||
|
||||
val result = repository.getDevices()
|
||||
|
||||
coVerify(exactly = 1) { devicesService.getDevices() }
|
||||
assertEquals(
|
||||
GetDevicesResult.Success(
|
||||
devices = listOf(
|
||||
DeviceInfo(
|
||||
id = "deviceId",
|
||||
name = "Test Device",
|
||||
// identifier "deviceIdentifier" != uniqueAppId "testUniqueAppId"
|
||||
identifier = "deviceIdentifier",
|
||||
type = DeviceType.ANDROID,
|
||||
isTrusted = false,
|
||||
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
|
||||
lastActivityDate = null,
|
||||
pendingAuthRequest = null,
|
||||
isCurrentDevice = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPasswordBreachCount should return failure when service returns failure`() = runTest {
|
||||
val password = "password"
|
||||
|
||||
@@ -53,6 +53,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
|
||||
private var onNavigateToDeleteAccountCalled = false
|
||||
private var onNavigateToPendingRequestsCalled = false
|
||||
private var onNavigateToUnlockSetupScreenCalled = false
|
||||
private var onNavigateToManageDevicesCalled = false
|
||||
|
||||
private val intentManager = mockk<IntentManager> {
|
||||
every { launchUri(any()) } just runs
|
||||
@@ -93,6 +94,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
|
||||
onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true },
|
||||
onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true },
|
||||
onNavigateToSetupUnlockScreen = { onNavigateToUnlockSetupScreenCalled = true },
|
||||
onNavigateToManageDevices = { onNavigateToManageDevicesCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
@@ -1744,6 +1746,54 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
|
||||
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToSetupUnlockScreen)
|
||||
assertTrue(onNavigateToUnlockSetupScreenCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateToManageDevices event should call onNavigateToManageDevices`() {
|
||||
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToManageDevices)
|
||||
assertTrue(onNavigateToManageDevicesCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage devices row should be visible when isManageDevicesEnabled is true`() {
|
||||
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Manage devices")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage devices row should not be visible when isManageDevicesEnabled is false`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Manage devices")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending login requests row should be visible when isManageDevicesEnabled is false`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Pending login requests")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending login requests row should not be visible when isManageDevicesEnabled is true`() {
|
||||
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Pending login requests")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on manage devices click should send ManageDevicesClick action`() {
|
||||
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Manage devices")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(AccountSecurityAction.ManageDevicesClick) }
|
||||
}
|
||||
}
|
||||
|
||||
private val CIPHER = mockk<Cipher>()
|
||||
@@ -1755,6 +1805,7 @@ private val DEFAULT_STATE = AccountSecurityState(
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
isUnlockWithPasswordEnabled = true,
|
||||
isUnlockWithPinEnabled = false,
|
||||
isManageDevicesEnabled = false,
|
||||
userId = USER_ID,
|
||||
shouldShowEnableAuthenticatorSync = false,
|
||||
vaultTimeout = VaultTimeout.ThirtyMinutes,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
@@ -21,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
@@ -78,6 +80,16 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
||||
every { isUnlockWithPinEnabledFlow } returns mutablePinUnlockEnabledFlow
|
||||
}
|
||||
|
||||
private val mutableManageDevicesFlagFlow = MutableStateFlow(false)
|
||||
private val featureFlagManager: FeatureFlagManager = mockk {
|
||||
every {
|
||||
getFeatureFlag(FlagKey.ManageDevices)
|
||||
} answers {
|
||||
mutableManageDevicesFlagFlow.value
|
||||
}
|
||||
every { getFeatureFlagFlow(FlagKey.ManageDevices) } returns mutableManageDevicesFlagFlow
|
||||
}
|
||||
|
||||
private val mutableFirstTimeStateFlow = MutableStateFlow(FirstTimeState())
|
||||
private val firstTimeActionManager: FirstTimeActionManager = mockk {
|
||||
every { firstTimeStateFlow } returns mutableFirstTimeStateFlow
|
||||
@@ -874,6 +886,26 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ManageDevicesClick should emit NavigateToManageDevices`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(AccountSecurityAction.ManageDevicesClick)
|
||||
assertEquals(AccountSecurityEvent.NavigateToManageDevices, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ManageDevices flag updates, should update isManageDevicesEnabled state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
mutableManageDevicesFlagFlow.emit(true)
|
||||
val expectedState = DEFAULT_STATE.copy(isManageDevicesEnabled = true)
|
||||
assertEquals(
|
||||
viewModel.stateFlow.value,
|
||||
expectedState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun createViewModel(
|
||||
initialState: AccountSecurityState? = DEFAULT_STATE,
|
||||
@@ -881,12 +913,14 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
||||
vaultRepository: VaultRepository = this.vaultRepository,
|
||||
environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository,
|
||||
settingsRepository: SettingsRepository = this.settingsRepository,
|
||||
featureFlagManager: FeatureFlagManager = this.featureFlagManager,
|
||||
policyManager: PolicyManager = this.policyManager,
|
||||
): AccountSecurityViewModel = AccountSecurityViewModel(
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
environmentRepository = environmentRepository,
|
||||
featureFlagManager = featureFlagManager,
|
||||
policyManager = policyManager,
|
||||
savedStateHandle = SavedStateHandle().apply {
|
||||
set("state", initialState)
|
||||
@@ -961,6 +995,7 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
isUnlockWithPasswordEnabled = true,
|
||||
isUnlockWithPinEnabled = false,
|
||||
isManageDevicesEnabled = false,
|
||||
userId = DEFAULT_USER_ID,
|
||||
vaultTimeout = VaultTimeout.ThirtyMinutes,
|
||||
vaultTimeoutAction = VaultTimeoutAction.LOCK,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.advanceTimeByAndRunCurrent
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ManageDevicesScreenTest : BitwardenComposeTest() {
|
||||
|
||||
private var onNavigateBackCalled = false
|
||||
private var navigateToLoginApprovalFingerprint: String? = null
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ManageDevicesEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<ManageDevicesViewModel> {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { trySendAction(any()) } just runs
|
||||
}
|
||||
private val permissionsManager = FakePermissionManager().apply {
|
||||
checkPermissionResult = false
|
||||
shouldShowRequestRationale = false
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
setContent(
|
||||
permissionsManager = permissionsManager,
|
||||
) {
|
||||
ManageDevicesScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToLoginApproval = { fingerprint ->
|
||||
navigateToLoginApprovalFingerprint = fingerprint
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ManageDevicesEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateToLoginApproval should call onNavigateToLoginApproval with fingerprint`() {
|
||||
val fingerprint = "mock-fingerprint"
|
||||
mutableEventFlow.tryEmit(ManageDevicesEvent.NavigateToLoginApproval(fingerprint))
|
||||
assertEquals(fingerprint, navigateToLoginApprovalFingerprint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShowSnackbar event should display snackbar message`() {
|
||||
val message = "Test snackbar message"
|
||||
val data = BitwardenSnackbarData(message = message.asText())
|
||||
composeTestRule.onNodeWithText(message).assertDoesNotExist()
|
||||
mutableEventFlow.tryEmit(ManageDevicesEvent.ShowSnackbar(data))
|
||||
composeTestRule.onNodeWithText(message).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on close button click should send CloseClick action`() {
|
||||
// Hide bottom sheet so only the AppBar close button exists
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(internalHideBottomSheet = true)
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Close")
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(ManageDevicesAction.CloseClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading state should display loading content`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Loading,
|
||||
)
|
||||
// Loading spinner is shown – no device cells
|
||||
composeTestRule.onNodeWithTag("LoginRequestCell").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error state should display error message`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Error,
|
||||
internalHideBottomSheet = true,
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText(
|
||||
"We were unable to process your request. " +
|
||||
"Please try again or contact us.",
|
||||
)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content state with current session device should display current session indicator`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
DEFAULT_DEVICE_ITEM.copy(status = DeviceSessionStatus.Current),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule.onNodeWithText("Current session").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content state with trusted device should display trusted label`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
DEFAULT_DEVICE_ITEM.copy(
|
||||
status = DeviceSessionStatus.None,
|
||||
isTrusted = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule.onNodeWithText("Trusted").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content state with pending device should display pending request indicator`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
DEFAULT_DEVICE_ITEM.copy(
|
||||
status = DeviceSessionStatus.Pending,
|
||||
fingerprintPhrase = "mock-fingerprint",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule.onNodeWithText("Pending request").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking pending request row should send PendingRequestRowClick action`() {
|
||||
val fingerprint = "test-fingerprint"
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
DEFAULT_DEVICE_ITEM.copy(
|
||||
status = DeviceSessionStatus.Pending,
|
||||
fingerprintPhrase = fingerprint,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithTag("LoginRequestCell")
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(ManageDevicesAction.PendingRequestRowClick(fingerprint)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on skip for now click should send HideBottomSheet action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now")
|
||||
.performScrollTo()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L)
|
||||
verify { viewModel.trySendAction(ManageDevicesAction.HideBottomSheet) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bottom sheet should not show when permission already granted`() {
|
||||
permissionsManager.checkPermissionResult = true
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(devicesLoaded = true)
|
||||
composeTestRule.onNodeWithText("Skip for now").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content state should display device type name`() {
|
||||
val typeName = "Mobile - Android"
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
DEFAULT_DEVICE_ITEM.copy(typeName = typeName.asText()),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule.onNodeWithText(typeName).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on lifecycle resume should send LifecycleResume action`() = runTest {
|
||||
// The LifecycleEventEffect fires ON_RESUME - verify it was called on setup
|
||||
verify { viewModel.trySendAction(ManageDevicesAction.LifecycleResume) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_DEVICE_ITEM = ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = "device-1",
|
||||
name = "Test Device",
|
||||
typeName = "Mobile - Android".asText(),
|
||||
isTrusted = false,
|
||||
firstLoginDate = "Oct 27, 2023, 12:00:00 PM",
|
||||
lastActivityLabel = "Active today".asText(),
|
||||
status = DeviceSessionStatus.None,
|
||||
fingerprintPhrase = null,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE = ManageDevicesState(
|
||||
authRequests = persistentListOf(),
|
||||
devices = persistentListOf(),
|
||||
viewState = ManageDevicesState.ViewState.Loading,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
)
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DevicePendingAuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.readableDeviceTypeName
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.toLastActivityLabel
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.TemporalAccessor
|
||||
|
||||
class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val mutableAuthRequestsWithUpdatesFlow =
|
||||
bufferedMutableSharedFlow<AuthRequestsUpdatesResult>()
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { getAuthRequestsWithUpdates() } returns mutableAuthRequestsWithUpdatesFlow
|
||||
coEvery { getDevices() } returns GetDevicesResult.Success(emptyList())
|
||||
}
|
||||
private val mutablePullToRefreshStateFlow = MutableStateFlow(false)
|
||||
private val settingsRepository = mockk<SettingsRepository> {
|
||||
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow
|
||||
}
|
||||
private val mutableSnackbarDataFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
||||
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
|
||||
every {
|
||||
getSnackbarDataFlow(relay = any(), relays = anyVararg())
|
||||
} returns mutableSnackbarDataFlow
|
||||
}
|
||||
private val buildInfoManager = mockk<BuildInfoManager> {
|
||||
every { isFdroid } returns false
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(TemporalAccessor::toFormattedDateTimeStyle)
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(TemporalAccessor::toFormattedDateTimeStyle)
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should make necessary network calls`() {
|
||||
createViewModel()
|
||||
coVerify {
|
||||
authRepository.getAuthRequestsWithUpdates()
|
||||
authRepository.getDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should set devicesLoaded true after device fetch success`() {
|
||||
val viewModel = createViewModel()
|
||||
// After init with unconfined dispatcher, devices coroutine runs immediately
|
||||
assertEquals(true, viewModel.stateFlow.value.devicesLoaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManageDevicesAction.CloseClick)
|
||||
assertEquals(ManageDevicesEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HideBottomSheet should set hideBottomSheet to true`() {
|
||||
val viewModel = createViewModel()
|
||||
assertFalse(viewModel.stateFlow.value.hideBottomSheet)
|
||||
viewModel.trySendAction(ManageDevicesAction.HideBottomSheet)
|
||||
assertTrue(viewModel.stateFlow.value.hideBottomSheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LifecycleResume should re-fetch auth requests only`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ManageDevicesAction.LifecycleResume)
|
||||
// getAuthRequestsWithUpdates called twice: once on init, once on resume
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
coVerify(exactly = 1) { authRepository.getDevices() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshPull when devices loaded should re-fetch auth requests only`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1)
|
||||
|
||||
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
|
||||
|
||||
coVerify(exactly = 1) { authRepository.getDevices() }
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshPull when devices failed should re-fetch both devices and auth requests`() =
|
||||
runTest {
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1)
|
||||
|
||||
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
|
||||
|
||||
coVerify(exactly = 2) { authRepository.getDevices() }
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PendingRequestRowClick should emit NavigateToLoginApproval`() = runTest {
|
||||
val fingerprint = "mock-fingerprint"
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManageDevicesAction.PendingRequestRowClick(fingerprint))
|
||||
assertEquals(
|
||||
ManageDevicesEvent.NavigateToLoginApproval(fingerprint),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when getDevices returns error should show error state`() {
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
ManageDevicesState(
|
||||
authRequests = persistentListOf(),
|
||||
devices = persistentListOf(),
|
||||
viewState = ManageDevicesState.ViewState.Error,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AuthRequestsResultReceive with error should use empty auth request list`() {
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Error(error = Throwable()),
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<AuthRequest>(),
|
||||
viewModel.stateFlow.value.authRequests,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates to pull-to-refresh enabled state should update isPullToRefreshEnabled`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
// Transition to Content state so isPullToRefreshEnabled can become true
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
val contentState = awaitItem()
|
||||
assertTrue(contentState.viewState is ManageDevicesState.ViewState.Content)
|
||||
assertFalse(contentState.isPullToRefreshEnabled)
|
||||
mutablePullToRefreshStateFlow.value = true
|
||||
val updatedState = awaitItem()
|
||||
assertTrue(updatedState.isPullToRefreshEnabled)
|
||||
mutablePullToRefreshStateFlow.value = false
|
||||
val revertedState = awaitItem()
|
||||
assertFalse(revertedState.isPullToRefreshEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SnackbarDataReceive should emit ShowSnackbar event`() = runTest {
|
||||
val data = BitwardenSnackbarData(message = "test".asText())
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
mutableSnackbarDataFlow.tryEmit(data)
|
||||
assertEquals(ManageDevicesEvent.ShowSnackbar(data), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content state should sort devices with current first, pending second, others last`() =
|
||||
runTest {
|
||||
val pendingRequest = DevicePendingAuthRequest(
|
||||
id = "auth-req-1",
|
||||
creationDate = fixedClock.instant(),
|
||||
)
|
||||
val validAuthRequest = AuthRequest(
|
||||
id = "auth-req-1",
|
||||
publicKey = "publicKey",
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = null,
|
||||
masterPasswordHash = null,
|
||||
creationDate = fixedClock.instant(),
|
||||
responseDate = null,
|
||||
requestApproved = false,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = "fingerprint-phrase",
|
||||
)
|
||||
val currentDevice = DEFAULT_DEVICE.copy(isCurrentDevice = true)
|
||||
val pendingDevice = DEFAULT_DEVICE.copy(
|
||||
id = "device-pending",
|
||||
pendingAuthRequest = pendingRequest,
|
||||
)
|
||||
val otherDevice = DEFAULT_DEVICE.copy(id = "device-other")
|
||||
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Success(
|
||||
devices = listOf(otherDevice, pendingDevice, currentDevice),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = listOf(validAuthRequest)),
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
ManageDevicesState(
|
||||
authRequests = listOf(validAuthRequest).toImmutableList(),
|
||||
devices = listOf(
|
||||
otherDevice,
|
||||
pendingDevice,
|
||||
currentDevice,
|
||||
).toImmutableList(),
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = currentDevice.id,
|
||||
name = currentDevice.name,
|
||||
typeName = currentDevice.type.readableDeviceTypeName,
|
||||
isTrusted = currentDevice.isTrusted,
|
||||
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
|
||||
lastActivityLabel = currentDevice.lastActivityDate
|
||||
?.toLastActivityLabel(clock = fixedClock),
|
||||
status = DeviceSessionStatus.Current,
|
||||
fingerprintPhrase = null,
|
||||
),
|
||||
ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = pendingDevice.id,
|
||||
name = pendingDevice.name,
|
||||
typeName = pendingDevice.type.readableDeviceTypeName,
|
||||
isTrusted = pendingDevice.isTrusted,
|
||||
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
|
||||
lastActivityLabel = pendingDevice.lastActivityDate
|
||||
?.toLastActivityLabel(clock = fixedClock),
|
||||
status = DeviceSessionStatus.Pending,
|
||||
fingerprintPhrase = validAuthRequest.fingerprint,
|
||||
),
|
||||
ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = otherDevice.id,
|
||||
name = otherDevice.name,
|
||||
typeName = otherDevice.type.readableDeviceTypeName,
|
||||
isTrusted = otherDevice.isTrusted,
|
||||
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
|
||||
lastActivityLabel = otherDevice.lastActivityDate
|
||||
?.toLastActivityLabel(clock = fixedClock),
|
||||
status = DeviceSessionStatus.None,
|
||||
fingerprintPhrase = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = true,
|
||||
authRequestsLoaded = true,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(state: ManageDevicesState? = null) = ManageDevicesViewModel(
|
||||
clock = fixedClock,
|
||||
authRepository = authRepository,
|
||||
snackbarRelayManager = snackbarRelayManager,
|
||||
settingsRepository = settingsRepository,
|
||||
buildInfoManager = buildInfoManager,
|
||||
savedStateHandle = SavedStateHandle(mapOf("state" to state)),
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_DEVICE = DeviceInfo(
|
||||
id = "device-current",
|
||||
name = "Test Device",
|
||||
identifier = "identifier-current",
|
||||
type = DeviceType.ANDROID,
|
||||
isTrusted = false,
|
||||
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
|
||||
lastActivityDate = Instant.parse("2023-10-27T12:00:00Z"),
|
||||
pendingAuthRequest = null,
|
||||
isCurrentDevice = false,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
|
||||
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class DeviceLastActivityExtensionsTest {
|
||||
|
||||
private val fixedClock = Clock.fixed(
|
||||
Instant.parse("2024-03-15T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same day returns today`() {
|
||||
val activityDate = Instant.parse("2024-03-15T00:00:00Z")
|
||||
assertEquals(BitwardenString.today.asText(), activityDate.toLastActivityLabel(fixedClock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `future date returns today`() {
|
||||
val activityDate = Instant.parse("2024-03-16T00:00:00Z")
|
||||
assertEquals(BitwardenString.today.asText(), activityDate.toLastActivityLabel(fixedClock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `1 day ago returns past seven days`() {
|
||||
val activityDate = Instant.parse("2024-03-14T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_seven_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `6 days ago returns past seven days`() {
|
||||
val activityDate = Instant.parse("2024-03-09T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_seven_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `7 days ago returns past fourteen days`() {
|
||||
val activityDate = Instant.parse("2024-03-08T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_fourteen_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `13 days ago returns past fourteen days`() {
|
||||
val activityDate = Instant.parse("2024-03-02T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_fourteen_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `14 days ago returns past thirty days`() {
|
||||
val activityDate = Instant.parse("2024-03-01T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_thirty_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `29 days ago returns past thirty days`() {
|
||||
val activityDate = Instant.parse("2024-02-15T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.past_thirty_days.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `30 days ago returns over thirty days ago`() {
|
||||
val activityDate = Instant.parse("2024-02-14T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.over_thirty_days_ago.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `31 days ago returns over thirty days ago`() {
|
||||
val activityDate = Instant.parse("2024-02-13T00:00:00Z")
|
||||
assertEquals(
|
||||
BitwardenString.over_thirty_days_ago.asText(),
|
||||
activityDate.toLastActivityLabel(fixedClock),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
|
||||
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DynamicTest.dynamicTest
|
||||
import org.junit.jupiter.api.TestFactory
|
||||
|
||||
class DeviceTypeExtensionsTest {
|
||||
@TestFactory
|
||||
fun `readableDeviceTypeName returns correct value for each known device type`() =
|
||||
listOf(
|
||||
DeviceType.ANDROID to BitwardenString.mobile_platform.asText("Android"),
|
||||
DeviceType.IOS to BitwardenString.mobile_platform.asText("iOS"),
|
||||
DeviceType.CHROME_EXTENSION to BitwardenString.extension_platform.asText("Chrome"),
|
||||
DeviceType.FIREFOX_EXTENSION to BitwardenString.extension_platform.asText("Firefox"),
|
||||
DeviceType.OPERA_EXTENSION to BitwardenString.extension_platform.asText("Opera"),
|
||||
DeviceType.EDGE_EXTENSION to BitwardenString.extension_platform.asText("Edge"),
|
||||
DeviceType.WINDOWS_DESKTOP to BitwardenString.desktop_platform.asText("Windows"),
|
||||
DeviceType.MAC_OS_DESKTOP to BitwardenString.desktop_platform.asText("MacOS"),
|
||||
DeviceType.LINUX_DESKTOP to BitwardenString.desktop_platform.asText("Linux"),
|
||||
DeviceType.CHROME_BROWSER to BitwardenString.web_platform.asText("Chrome"),
|
||||
DeviceType.FIREFOX_BROWSER to BitwardenString.web_platform.asText("Firefox"),
|
||||
DeviceType.OPERA_BROWSER to BitwardenString.web_platform.asText("Opera"),
|
||||
DeviceType.EDGE_BROWSER to BitwardenString.web_platform.asText("Edge"),
|
||||
DeviceType.IE_BROWSER to BitwardenString.web_platform.asText("IE"),
|
||||
DeviceType.UNKNOWN_BROWSER to BitwardenString.web_platform.asText("Unknown"),
|
||||
DeviceType.ANDROID_AMAZON to BitwardenString.mobile_platform.asText("Amazon"),
|
||||
DeviceType.UWP to BitwardenString.desktop_platform.asText("Windows UWP"),
|
||||
DeviceType.SAFARI_BROWSER to BitwardenString.web_platform.asText("Safari"),
|
||||
DeviceType.VIVALDI_BROWSER to BitwardenString.web_platform.asText("Vivaldi"),
|
||||
DeviceType.VIVALDI_EXTENSION to BitwardenString.extension_platform.asText("Vivaldi"),
|
||||
DeviceType.SAFARI_EXTENSION to BitwardenString.extension_platform.asText("Safari"),
|
||||
DeviceType.SDK to BitwardenString.sdk.asText(),
|
||||
DeviceType.SERVER to BitwardenString.server.asText(),
|
||||
DeviceType.WINDOWS_CLI to BitwardenString.cli_platform.asText("Windows"),
|
||||
DeviceType.MAC_OS_CLI to BitwardenString.cli_platform.asText("MacOS"),
|
||||
DeviceType.LINUX_CLI to BitwardenString.cli_platform.asText("Linux"),
|
||||
DeviceType.DUCK_DUCK_GO_BROWSER to
|
||||
BitwardenString.extension_platform.asText("DuckDuckGo"),
|
||||
DeviceType.UNKNOWN to BitwardenString.unknown_device.asText(),
|
||||
).map { (type, expected) ->
|
||||
dynamicTest("$type should return $expected") {
|
||||
assertEquals(expected, type.readableDeviceTypeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() {
|
||||
onNavigateToFlightRecorder = {},
|
||||
onNavigateToRecordedLogs = {},
|
||||
onNavigateToAboutPrivilegedApps = {},
|
||||
onNavigateToManageDevices = {},
|
||||
onNavigateToPlan = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ sealed class FlagKey<out T : Any> {
|
||||
SendEmailVerification,
|
||||
CardScanner,
|
||||
MobilePremiumUpgrade,
|
||||
ManageDevices,
|
||||
AttachmentUpdates,
|
||||
V2EncryptionJitPassword,
|
||||
V2EncryptionKeyConnector,
|
||||
@@ -129,6 +130,14 @@ sealed class FlagKey<out T : Any> {
|
||||
override val defaultValue: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the feature flag key for the Manage Devices feature.
|
||||
*/
|
||||
data object ManageDevices : FlagKey<Boolean>() {
|
||||
override val keyName: String = "pm-4516-manage-devices"
|
||||
override val defaultValue: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the feature flag key for Encryption V2 pertaining to JIT Password.
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.bitwarden.network.api
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.bitwarden.network.model.DevicesResponseJson
|
||||
import com.bitwarden.network.model.NetworkResult
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysRequestJson
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
@@ -13,6 +15,9 @@ import retrofit2.http.Path
|
||||
*/
|
||||
@Keep
|
||||
internal interface AuthenticatedDevicesApi {
|
||||
@GET("/devices")
|
||||
suspend fun getDevices(): NetworkResult<DevicesResponseJson>
|
||||
|
||||
@PUT("/devices/{appId}/keys")
|
||||
suspend fun updateTrustedDeviceKeys(
|
||||
@Path(value = "appId") appId: String,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents a pending auth request associated with a device.
|
||||
*
|
||||
* @property id The unique identifier of the pending auth request.
|
||||
* @property creationDate The date and time on which this auth request was created.
|
||||
*/
|
||||
@Serializable
|
||||
data class DevicePendingAuthRequestJson(
|
||||
@SerialName("id") val id: String,
|
||||
@Contextual @SerialName("creationDate") val creationDate: Instant,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Response body for a single device registered to the current user.
|
||||
*
|
||||
* @property id The unique identifier of the device.
|
||||
* @property name The name of the device.
|
||||
* @property identifier The unique install identifier of the device.
|
||||
* @property type The type of the device.
|
||||
* @property creationDate The date and time on which this device was created.
|
||||
* @property isTrusted Whether this device is trusted.
|
||||
* @property encryptedUserKey The encrypted user key for this device, if available.
|
||||
* @property encryptedPublicKey The encrypted public key for this device, if available.
|
||||
* @property devicePendingAuthRequest The pending auth request for this device, if any.
|
||||
*/
|
||||
@Serializable
|
||||
data class DeviceResponseJson(
|
||||
@SerialName("id") val id: String,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("identifier") val identifier: String,
|
||||
@SerialName("type") val type: DeviceType,
|
||||
@Contextual @SerialName("creationDate") val creationDate: Instant,
|
||||
@Contextual @SerialName("lastActivityDate") val lastActivityDate: Instant?,
|
||||
@SerialName("isTrusted") val isTrusted: Boolean,
|
||||
@SerialName("encryptedUserKey") val encryptedUserKey: String?,
|
||||
@SerialName("encryptedPublicKey") val encryptedPublicKey: String?,
|
||||
@SerialName("devicePendingAuthRequest")
|
||||
val devicePendingAuthRequest: DevicePendingAuthRequestJson?,
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.bitwarden.core.data.serializer.BaseEnumeratedIntSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the type of registered device as returned by the server.
|
||||
*/
|
||||
@Serializable(DeviceTypeSerializer::class)
|
||||
enum class DeviceType {
|
||||
@SerialName("0")
|
||||
ANDROID,
|
||||
|
||||
@SerialName("1")
|
||||
IOS,
|
||||
|
||||
@SerialName("2")
|
||||
CHROME_EXTENSION,
|
||||
|
||||
@SerialName("3")
|
||||
FIREFOX_EXTENSION,
|
||||
|
||||
@SerialName("4")
|
||||
OPERA_EXTENSION,
|
||||
|
||||
@SerialName("5")
|
||||
EDGE_EXTENSION,
|
||||
|
||||
@SerialName("6")
|
||||
WINDOWS_DESKTOP,
|
||||
|
||||
@SerialName("7")
|
||||
MAC_OS_DESKTOP,
|
||||
|
||||
@SerialName("8")
|
||||
LINUX_DESKTOP,
|
||||
|
||||
@SerialName("9")
|
||||
CHROME_BROWSER,
|
||||
|
||||
@SerialName("10")
|
||||
FIREFOX_BROWSER,
|
||||
|
||||
@SerialName("11")
|
||||
OPERA_BROWSER,
|
||||
|
||||
@SerialName("12")
|
||||
EDGE_BROWSER,
|
||||
|
||||
@SerialName("13")
|
||||
IE_BROWSER,
|
||||
|
||||
@SerialName("14")
|
||||
UNKNOWN_BROWSER,
|
||||
|
||||
@SerialName("15")
|
||||
ANDROID_AMAZON,
|
||||
|
||||
@SerialName("16")
|
||||
UWP,
|
||||
|
||||
@SerialName("17")
|
||||
SAFARI_BROWSER,
|
||||
|
||||
@SerialName("18")
|
||||
VIVALDI_BROWSER,
|
||||
|
||||
@SerialName("19")
|
||||
VIVALDI_EXTENSION,
|
||||
|
||||
@SerialName("20")
|
||||
SAFARI_EXTENSION,
|
||||
|
||||
@SerialName("21")
|
||||
SDK,
|
||||
|
||||
@SerialName("22")
|
||||
SERVER,
|
||||
|
||||
@SerialName("23")
|
||||
WINDOWS_CLI,
|
||||
|
||||
@SerialName("24")
|
||||
MAC_OS_CLI,
|
||||
|
||||
@SerialName("25")
|
||||
LINUX_CLI,
|
||||
|
||||
@SerialName("26")
|
||||
DUCK_DUCK_GO_BROWSER,
|
||||
|
||||
/**
|
||||
* Represents an unknown device type.
|
||||
*
|
||||
* This is used for forward compatibility to handle new device types that the client doesn't
|
||||
* yet understand.
|
||||
*/
|
||||
@SerialName("-1")
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
@Keep
|
||||
private class DeviceTypeSerializer : BaseEnumeratedIntSerializer<DeviceType>(
|
||||
className = "DeviceType",
|
||||
values = DeviceType.entries.toTypedArray(),
|
||||
default = DeviceType.UNKNOWN,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response body for the list of devices registered to the current user.
|
||||
*
|
||||
* @property devices The list of devices.
|
||||
*/
|
||||
@Serializable
|
||||
data class DevicesResponseJson(
|
||||
@SerialName("data") val devices: List<DeviceResponseJson>,
|
||||
)
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.model.DevicesResponseJson
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
|
||||
|
||||
/**
|
||||
* Provides an API for interacting with the /devices endpoints.
|
||||
*/
|
||||
interface DevicesService {
|
||||
/**
|
||||
* Retrieves all devices registered to the current user.
|
||||
*/
|
||||
suspend fun getDevices(): Result<DevicesResponseJson>
|
||||
|
||||
/**
|
||||
* Check whether this device is known (and thus whether Login with Device is available).
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.api.AuthenticatedDevicesApi
|
||||
import com.bitwarden.network.api.UnauthenticatedDevicesApi
|
||||
import com.bitwarden.network.model.DevicesResponseJson
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysRequestJson
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
|
||||
import com.bitwarden.network.util.base64UrlEncode
|
||||
@@ -14,6 +15,9 @@ internal class DevicesServiceImpl(
|
||||
private val authenticatedDevicesApi: AuthenticatedDevicesApi,
|
||||
private val unauthenticatedDevicesApi: UnauthenticatedDevicesApi,
|
||||
) : DevicesService {
|
||||
override suspend fun getDevices(): Result<DevicesResponseJson> =
|
||||
authenticatedDevicesApi.getDevices().toResult()
|
||||
|
||||
override suspend fun getIsKnownDevice(
|
||||
emailAddress: String,
|
||||
deviceId: String,
|
||||
|
||||
@@ -4,6 +4,9 @@ import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.api.AuthenticatedDevicesApi
|
||||
import com.bitwarden.network.api.UnauthenticatedDevicesApi
|
||||
import com.bitwarden.network.base.BaseServiceTest
|
||||
import com.bitwarden.network.model.DeviceResponseJson
|
||||
import com.bitwarden.network.model.DeviceType
|
||||
import com.bitwarden.network.model.DevicesResponseJson
|
||||
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -22,6 +25,22 @@ class DevicesServiceTest : BaseServiceTest() {
|
||||
unauthenticatedDevicesApi = unauthenticatedDevicesApi,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getDevices when request response is Failure should return Failure`() = runTest {
|
||||
val response = MockResponse().setResponseCode(400)
|
||||
server.enqueue(response)
|
||||
val actual = service.getDevices()
|
||||
assertTrue(actual.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDevices when request response is Success should return Success`() = runTest {
|
||||
val response = MockResponse().setBody(GET_DEVICES_RESPONSE_JSON).setResponseCode(200)
|
||||
server.enqueue(response)
|
||||
val actual = service.getDevices()
|
||||
assertEquals(GET_DEVICES_RESPONSE.asSuccess(), actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getIsKnownDevice when request response is Failure should return Failure`() = runTest {
|
||||
val response = MockResponse().setResponseCode(400)
|
||||
@@ -65,6 +84,42 @@ class DevicesServiceTest : BaseServiceTest() {
|
||||
}
|
||||
}
|
||||
|
||||
private val GET_DEVICES_RESPONSE: DevicesResponseJson = DevicesResponseJson(
|
||||
devices = listOf(
|
||||
DeviceResponseJson(
|
||||
id = "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
|
||||
name = "Pixel 8",
|
||||
identifier = "ea7c0a13-5ce4-4f96-8e17-4fc7fa54f464",
|
||||
type = DeviceType.ANDROID,
|
||||
creationDate = Instant.parse("2024-03-25T18:04:28.23Z"),
|
||||
lastActivityDate = Instant.parse("2024-03-26T10:00:00.00Z"),
|
||||
isTrusted = true,
|
||||
encryptedUserKey = null,
|
||||
encryptedPublicKey = null,
|
||||
devicePendingAuthRequest = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val GET_DEVICES_RESPONSE_JSON: String = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
|
||||
"name": "Pixel 8",
|
||||
"identifier": "ea7c0a13-5ce4-4f96-8e17-4fc7fa54f464",
|
||||
"type": 0,
|
||||
"creationDate": "2024-03-25T18:04:28.23Z",
|
||||
"lastActivityDate": "2024-03-26T10:00:00.00Z",
|
||||
"isTrusted": true,
|
||||
"encryptedUserKey": null,
|
||||
"encryptedPublicKey": null,
|
||||
"devicePendingAuthRequest": null
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
private val TRUST_DEVICE_RESPONSE: TrustedDeviceKeysResponseJson =
|
||||
TrustedDeviceKeysResponseJson(
|
||||
id = "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
|
||||
|
||||
@@ -32,6 +32,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
|
||||
FlagKey.CardScanner,
|
||||
FlagKey.SendEmailVerification,
|
||||
FlagKey.MobilePremiumUpgrade,
|
||||
FlagKey.ManageDevices,
|
||||
FlagKey.AttachmentUpdates,
|
||||
FlagKey.V2EncryptionJitPassword,
|
||||
FlagKey.V2EncryptionKeyConnector,
|
||||
@@ -90,6 +91,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
|
||||
FlagKey.CardScanner -> stringResource(BitwardenString.scan_card)
|
||||
FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification)
|
||||
FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade)
|
||||
FlagKey.ManageDevices -> stringResource(BitwardenString.manage_devices_flag)
|
||||
FlagKey.AttachmentUpdates -> stringResource(BitwardenString.attachment_updates)
|
||||
FlagKey.V2EncryptionJitPassword -> stringResource(BitwardenString.v2_encryption_jit_password)
|
||||
FlagKey.V2EncryptionKeyConnector -> stringResource(BitwardenString.v2_encryption_key_connector)
|
||||
|
||||
@@ -1235,6 +1235,25 @@ Do you want to switch to this account?</string>
|
||||
<string name="continue_without_syncing">Continue without syncing</string>
|
||||
<string name="external_link">External link</string>
|
||||
<string name="external_link_format" comment="Used for accessibility to indicate that tapping this item will leave the app">%1$s, External link</string>
|
||||
<string name="manage_devices">Manage devices</string>
|
||||
<string name="mobile_platform">Mobile - %1$s</string>
|
||||
<string name="extension_platform">Extension - %1$s</string>
|
||||
<string name="web_platform">Web - %1$s</string>
|
||||
<string name="desktop_platform">Desktop - %1$s</string>
|
||||
<string name="cli_platform">CLI - %1$s</string>
|
||||
<string name="sdk">SDK</string>
|
||||
<string name="server">Server</string>
|
||||
<string name="unknown_device">Unknown device</string>
|
||||
<string name="first_login_date"><annotation emphasis="bold">First login:</annotation> <annotation arg="0">%1$s</annotation></string>
|
||||
<string name="current_session">Current session</string>
|
||||
<string name="pending_request">Pending request</string>
|
||||
<string name="today">Today</string>
|
||||
<string name="past_seven_days">Past 7 days</string>
|
||||
<string name="past_fourteen_days">Past 14 days</string>
|
||||
<string name="past_thirty_days">Past 30 days</string>
|
||||
<string name="over_thirty_days_ago">Over 30 days ago</string>
|
||||
<string name="recently_active"><annotation emphasis="bold">Recently active:</annotation> <annotation arg="0">%1$s</annotation></string>
|
||||
<string name="trusted">Trusted</string>
|
||||
<string name="preview_not_available_for_files">Preview unavailable for %1$s files. You can still download it to view on your device.</string>
|
||||
<string name="this_file_is_too_large_to_preview">This file is too large to preview. You can still download it to view on your device.</string>
|
||||
<string name="bitwarden_could_not_decrypt_this_file_so_the_preview_cannot_be_displayed">Bitwarden could not decrypt this file, so the preview cannot be displayed.</string>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<string name="v2_encryption_key_connector">V2 Encryption - Key Connector</string>
|
||||
<string name="v2_encryption_jit_password">V2 Encryption - JIT Password</string>
|
||||
<string name="v2_encryption_password">V2 Encryption - Password</string>
|
||||
<string name="manage_devices_flag">Manage devices</string>
|
||||
|
||||
<!-- endregion Debug Menu -->
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user