diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index fa45986d91d..a1d4c5d966a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index ecbc3123370..3af7a61350b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DeviceInfo.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DeviceInfo.kt new file mode 100644 index 00000000000..490f9750e5c --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DeviceInfo.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import android.os.Parcelable +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: Int, + val isTrusted: Boolean, + val creationDate: Instant, + val lastActivityDate: Instant?, + val pendingAuthRequest: DevicePendingAuthRequest?, + val isCurrentDevice: Boolean, +) : Parcelable diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DevicePendingAuthRequest.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DevicePendingAuthRequest.kt new file mode 100644 index 00000000000..56144c2d2e9 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/DevicePendingAuthRequest.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/GetDevicesResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/GetDevicesResult.kt new file mode 100644 index 00000000000..c17e8a7baf2 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/GetDevicesResult.kt @@ -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) : GetDevicesResult() + + /** + * There was an error retrieving the devices. + */ + data object Error : GetDevicesResult() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DeviceResponseJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DeviceResponseJsonExtensions.kt new file mode 100644 index 00000000000..49149e9fd5e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DeviceResponseJsonExtensions.kt @@ -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, + ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index bf36fc5a861..3e52713bbd7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -120,6 +120,7 @@ fun NavGraphBuilder.settingsGraph( onNavigateToImportLogins: () -> Unit, onNavigateToImportItems: () -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, + onNavigateToManageDevices: () -> Unit, ) { navigation( startDestination = SettingsRoute.Standard, @@ -147,6 +148,7 @@ fun NavGraphBuilder.settingsGraph( onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToPendingRequests = onNavigateToPendingRequests, onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, + onNavigateToManageDevices = onNavigateToManageDevices, ) appearanceDestination( isPreAuth = false, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt index d6af7f2b372..136001e3906 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityNavigation.kt @@ -20,6 +20,7 @@ fun NavGraphBuilder.accountSecurityDestination( onNavigateToDeleteAccount: () -> Unit, onNavigateToPendingRequests: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, + onNavigateToManageDevices: () -> Unit, ) { composableWithPushTransitions { AccountSecurityScreen( @@ -27,6 +28,7 @@ fun NavGraphBuilder.accountSecurityDestination( onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToPendingRequests = onNavigateToPendingRequests, onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, + onNavigateToManageDevices = onNavigateToManageDevices, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 717aaac8ab6..0b20c9097a4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -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() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index a4d8a82a2eb..18fd84c8fea 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -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( @@ -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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesNavigation.kt new file mode 100644 index 00000000000..fc46f6b39bb --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesNavigation.kt @@ -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 { + ManageDevicesScreen( + onNavigateBack = onNavigateBack, + onNavigateToLoginApproval = onNavigateToLoginApproval, + ) + } +} + +/** + * Navigate to the Manage Devices screen. + */ +fun NavController.navigateToManageDevices( + navOptions: NavOptions? = null, +) { + this.navigate(route = ManageDevicesRoute, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreen.kt new file mode 100644 index 00000000000..3dfce965e6c --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreen.kt @@ -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()) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModel.kt new file mode 100644 index 00000000000..034f63097cc --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModel.kt @@ -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, + settingsRepository: SettingsRepository, + buildInfoManager: BuildInfoManager, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + 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 { 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, + val devices: ImmutableList, + 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, + ) : 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.filterRespondedAndExpired(clock: Clock) = + filterNot { request -> + request.requestApproved || + request.responseDate != null || + request.creationDate.isOverFiveMinutesOld(clock) + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensions.kt new file mode 100644 index 00000000000..0eefa837702 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensions.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensions.kt new file mode 100644 index 00000000000..ee6f1136d0c --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensions.kt @@ -0,0 +1,42 @@ +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 + +/** + * Converts a device type integer to a human-readable display name. + * Returns e.g. "Mobile - Android", "Extension - Chrome", "Desktop - Windows". + */ +@Suppress("CyclomaticComplexMethod", "MagicNumber") +val Int.readableDeviceTypeName: Text + get() = when (this) { + 0 -> BitwardenString.mobile_platform.asText("Android") + 1 -> BitwardenString.mobile_platform.asText("iOS") + 2 -> BitwardenString.extension_platform.asText("Chrome") + 3 -> BitwardenString.extension_platform.asText("Firefox") + 4 -> BitwardenString.extension_platform.asText("Opera") + 5 -> BitwardenString.extension_platform.asText("Edge") + 6 -> BitwardenString.desktop_platform.asText("Windows") + 7 -> BitwardenString.desktop_platform.asText("MacOS") + 8 -> BitwardenString.desktop_platform.asText("Linux") + 9 -> BitwardenString.web_platform.asText("Chrome") + 10 -> BitwardenString.web_platform.asText("Firefox") + 11 -> BitwardenString.web_platform.asText("Opera") + 12 -> BitwardenString.web_platform.asText("Edge") + 13 -> BitwardenString.web_platform.asText("IE") + 14 -> BitwardenString.web_platform.asText("Unknown") + 15 -> BitwardenString.mobile_platform.asText("Amazon") + 16 -> BitwardenString.desktop_platform.asText("Windows UWP") + 17 -> BitwardenString.web_platform.asText("Safari") + 18 -> BitwardenString.web_platform.asText("Vivaldi") + 19 -> BitwardenString.extension_platform.asText("Vivaldi") + 20 -> BitwardenString.extension_platform.asText("Safari") + 21 -> BitwardenString.sdk.asText() + 22 -> BitwardenString.server.asText() + 23 -> BitwardenString.cli_platform.asText("Windows") + 24 -> BitwardenString.cli_platform.asText("MacOS") + 25 -> BitwardenString.cli_platform.asText("Linux") + 26 -> BitwardenString.extension_platform.asText("DuckDuckGo") + else -> BitwardenString.unknown_device.asText() + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 4fe530acd5c..ce109bfa9dd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 7230b225ca5..fd364f6137f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -52,6 +52,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, + onNavigateToManageDevices: () -> Unit, onNavigateToPlan: () -> Unit, ) { composableWithStayTransitions { @@ -76,6 +77,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToFlightRecorder = onNavigateToFlightRecorder, onNavigateToRecordedLogs = onNavigateToRecordedLogs, onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps, + onNavigateToManageDevices = onNavigateToManageDevices, onNavigateToPlan = onNavigateToPlan, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 2847732a2ac..f1e9a4fdf16 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -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, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 35d49540d34..7ba36fd31ac 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -31,6 +31,8 @@ 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.DevicesResponseJson import com.bitwarden.network.model.GetTokenResponseJson import com.bitwarden.network.model.IdentityTokenAuthModel import com.bitwarden.network.model.KdfJson @@ -99,6 +101,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 @@ -6874,6 +6877,45 @@ 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 = 0, + 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() } + assertTrue(result is GetDevicesResult.Success) + assertEquals(1, (result as GetDevicesResult.Success).devices.size) + val device = result.devices.first() + assertEquals("deviceId", device.id) + // identifier "deviceIdentifier" != uniqueAppId "testUniqueAppId", so not current device + assertFalse(device.isCurrentDevice) + } + @Test fun `getPasswordBreachCount should return failure when service returns failure`() = runTest { val password = "password" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 28b5235b0a9..375f1a21222 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -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 { 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() @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 2510e6b17ee..1a1ee95434e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreenTest.kt new file mode 100644 index 00000000000..5ca806f7a01 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk { + 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModelTest.kt new file mode 100644 index 00000000000..20b7bb979b6 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModelTest.kt @@ -0,0 +1,341 @@ +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.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() + private val authRepository = mockk { + every { getAuthRequestsWithUpdates() } returns mutableAuthRequestsWithUpdatesFlow + coEvery { getDevices() } returns GetDevicesResult.Success(emptyList()) + } + private val mutablePullToRefreshStateFlow = MutableStateFlow(false) + private val settingsRepository = mockk { + every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow + } + private val mutableSnackbarDataFlow = bufferedMutableSharedFlow() + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { + getSnackbarDataFlow(relay = any(), relays = anyVararg()) + } returns mutableSnackbarDataFlow + } + private val buildInfoManager = mockk { + 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(), + 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 = 0, + isTrusted = false, + creationDate = Instant.parse("2023-10-27T12:00:00Z"), + lastActivityDate = Instant.parse("2023-10-27T12:00:00Z"), + pendingAuthRequest = null, + isCurrentDevice = false, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensionsTest.kt new file mode 100644 index 00000000000..58c5677e610 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceLastActivityExtensionsTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensionsTest.kt new file mode 100644 index 00000000000..e807d0938d2 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/util/DeviceTypeExtensionsTest.kt @@ -0,0 +1,161 @@ +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 + +class DeviceTypeExtensionsTest { + + @Test + fun `type 0 should return Mobile - Android`() { + assertEquals( + BitwardenString.mobile_platform.asText("Android"), + 0.readableDeviceTypeName, + ) + } + + @Test + fun `type 1 should return Mobile - iOS`() { + assertEquals( + BitwardenString.mobile_platform.asText("iOS"), + 1.readableDeviceTypeName, + ) + } + + @Test + fun `type 2 should return Extension - Chrome`() { + assertEquals( + BitwardenString.extension_platform.asText("Chrome"), + 2.readableDeviceTypeName, + ) + } + + @Test + fun `type 3 should return Extension - Firefox`() { + assertEquals( + BitwardenString.extension_platform.asText("Firefox"), + 3.readableDeviceTypeName, + ) + } + + @Test + fun `type 5 should return Extension - Edge`() { + assertEquals( + BitwardenString.extension_platform.asText("Edge"), + 5.readableDeviceTypeName, + ) + } + + @Test + fun `type 6 should return Desktop - Windows`() { + assertEquals( + BitwardenString.desktop_platform.asText("Windows"), + 6.readableDeviceTypeName, + ) + } + + @Test + fun `type 7 should return Desktop - MacOS`() { + assertEquals( + BitwardenString.desktop_platform.asText("MacOS"), + 7.readableDeviceTypeName, + ) + } + + @Test + fun `type 9 should return Web - Chrome`() { + assertEquals( + BitwardenString.web_platform.asText("Chrome"), + 9.readableDeviceTypeName, + ) + } + + @Test + fun `type 15 should return Mobile - Amazon`() { + assertEquals( + BitwardenString.mobile_platform.asText("Amazon"), + 15.readableDeviceTypeName, + ) + } + + @Test + fun `type 16 should return Desktop - Windows UWP`() { + assertEquals( + BitwardenString.desktop_platform.asText("Windows UWP"), + 16.readableDeviceTypeName, + ) + } + + @Test + fun `type 20 should return Extension - Safari`() { + assertEquals( + BitwardenString.extension_platform.asText("Safari"), + 20.readableDeviceTypeName, + ) + } + + @Test + fun `type 21 should return SDK category only with no platform suffix`() { + assertEquals( + BitwardenString.sdk.asText(), + 21.readableDeviceTypeName, + ) + } + + @Test + fun `type 22 should return Server category only with no platform suffix`() { + assertEquals( + BitwardenString.server.asText(), + 22.readableDeviceTypeName, + ) + } + + @Test + fun `type 23 should return CLI - Windows`() { + assertEquals( + BitwardenString.cli_platform.asText("Windows"), + 23.readableDeviceTypeName, + ) + } + + @Test + fun `type 24 should return CLI - MacOS`() { + assertEquals( + BitwardenString.cli_platform.asText("MacOS"), + 24.readableDeviceTypeName, + ) + } + + @Test + fun `type 25 should return CLI - Linux`() { + assertEquals( + BitwardenString.cli_platform.asText("Linux"), + 25.readableDeviceTypeName, + ) + } + + @Test + fun `type 26 should return Extension - DuckDuckGo`() { + assertEquals( + BitwardenString.extension_platform.asText("DuckDuckGo"), + 26.readableDeviceTypeName, + ) + } + + @Test + fun `unknown type should return unknown device string`() { + assertEquals( + BitwardenString.unknown_device.asText(), + 999.readableDeviceTypeName, + ) + } + + @Test + fun `negative type should return unknown device string`() { + assertEquals( + BitwardenString.unknown_device.asText(), + (-1).readableDeviceTypeName, + ) + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index bc5645844fc..8d7201c8333 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() { onNavigateToFlightRecorder = {}, onNavigateToRecordedLogs = {}, onNavigateToAboutPrivilegedApps = {}, + onNavigateToManageDevices = {}, onNavigateToPlan = {}, ) } diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 419008df248..80b8e05441f 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -38,6 +38,7 @@ sealed class FlagKey { SendEmailVerification, CardScanner, MobilePremiumUpgrade, + ManageDevices, AttachmentUpdates, V2EncryptionJitPassword, V2EncryptionKeyConnector, @@ -129,6 +130,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the Manage Devices feature. + */ + data object ManageDevices : FlagKey() { + 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. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedDevicesApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedDevicesApi.kt index cb8449b8483..b43de63fb55 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedDevicesApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedDevicesApi.kt @@ -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 + @PUT("/devices/{appId}/keys") suspend fun updateTrustedDeviceKeys( @Path(value = "appId") appId: String, diff --git a/network/src/main/kotlin/com/bitwarden/network/model/DevicePendingAuthRequestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/DevicePendingAuthRequestJson.kt new file mode 100644 index 00000000000..3cb6f413038 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/DevicePendingAuthRequestJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/DeviceResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/DeviceResponseJson.kt new file mode 100644 index 00000000000..9a2cafc8136 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/DeviceResponseJson.kt @@ -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: Int, + @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?, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/DevicesResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/DevicesResponseJson.kt new file mode 100644 index 00000000000..f646b545769 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/DevicesResponseJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/DevicesService.kt b/network/src/main/kotlin/com/bitwarden/network/service/DevicesService.kt index 9d675a06379..21bbe53c816 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/DevicesService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/DevicesService.kt @@ -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 + /** * Check whether this device is known (and thus whether Login with Device is available). */ diff --git a/network/src/main/kotlin/com/bitwarden/network/service/DevicesServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/DevicesServiceImpl.kt index 831db099604..1a5793274b8 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/DevicesServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/DevicesServiceImpl.kt @@ -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 = + authenticatedDevicesApi.getDevices().toResult() + override suspend fun getIsKnownDevice( emailAddress: String, deviceId: String, diff --git a/network/src/test/kotlin/com/bitwarden/network/service/DevicesServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/DevicesServiceTest.kt index 4115f0d7693..81a0f5e3608 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/DevicesServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/DevicesServiceTest.kt @@ -4,6 +4,8 @@ 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.DevicesResponseJson import com.bitwarden.network.model.TrustedDeviceKeysResponseJson import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse @@ -22,6 +24,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 +83,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 = 0, + 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", diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 6dad716fa41..c3e7970e531 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -32,6 +32,7 @@ fun FlagKey.ListItemContent( FlagKey.CardScanner, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, + FlagKey.ManageDevices, FlagKey.AttachmentUpdates, FlagKey.V2EncryptionJitPassword, FlagKey.V2EncryptionKeyConnector, @@ -90,6 +91,7 @@ private fun FlagKey.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) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index df168004698..ed232ac59c7 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1235,6 +1235,25 @@ Do you want to switch to this account? Continue without syncing External link %1$s, External link + Manage devices + Mobile - %1$s + Extension - %1$s + Web - %1$s + Desktop - %1$s + CLI - %1$s + SDK + Server + Unknown device + First login: %1$s + Current session + Pending request + Today + Past 7 days + Past 14 days + Past 30 days + Over 30 days ago + Recently active: %1$s + Trusted Preview unavailable for %1$s files. You can still download it to view on your device. This file is too large to preview. You can still download it to view on your device. Bitwarden could not decrypt this file, so the preview cannot be displayed. diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index dcfc8841828..b15d8807a44 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -50,6 +50,7 @@ V2 Encryption - Key Connector V2 Encryption - JIT Password V2 Encryption - Password + Manage devices