Compare commits

...

23 Commits

Author SHA1 Message Date
Andre Rosado
446f375a39 swapped type = 0 to DeviceType.Android on tests 2026-05-01 16:01:22 +01:00
Andre Rosado
dad449efe3 Moved DeviceType into network layer and using it on the DeviceResponseJson 2026-05-01 14:26:56 +01:00
Andre Rosado
113e157214 changed DeviceInfo type from int to DeviceType 2026-04-30 18:41:07 +01:00
Andre Rosado
08fe476d2d reduced boilerplate code from tests 2026-04-30 14:53:39 +01:00
Andre Rosado
c2e64cd75a fixed test to verify whole result 2026-04-29 19:14:35 +01:00
Andre Rosado
07d72b6805 Merge branch 'main' into PM-33982/build-device-screen
# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
2026-04-29 17:41:31 +01:00
Andre Rosado
bd621e88b7 removed nullable from toLastActivityLabel 2026-04-29 17:25:56 +01:00
Andre Rosado
ca76823b2f fixed date on tests 2026-04-28 17:33:53 +01:00
Andre Rosado
1b6851163f fixed tests 2026-04-28 15:43:49 +01:00
Andre Rosado
2e7148368e Merge branch 'main' into PM-33982/build-device-screen 2026-04-24 15:06:57 +01:00
Andre Rosado
5c22ee8f31 simplified code 2026-04-24 14:59:03 +01:00
Andre Rosado
5b731bb38d fixed tests 2026-04-21 16:10:36 +01:00
Andre Rosado
2d4a5361da Merge branch 'main' into PM-33982/build-device-screen
# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
2026-04-21 14:39:23 +01:00
Andre Rosado
8a96228ac8 Set state lists to immutable on ManageDeviceViewModel 2026-04-21 14:05:07 +01:00
Andre Rosado
64e2e985e9 Removed unnecessary api call. Refactored methods to use existing currentDeviceId.
Better string naming
Added more tests
2026-04-21 13:11:39 +01:00
Andre Rosado
7ef963684e Merge branch 'main' into PM-33982/build-device-screen 2026-04-16 16:15:20 +01:00
Andre Rosado
721cd2f7f4 removed ResourceManager from UI extension methods 2026-04-13 17:05:36 +01:00
Andre Rosado
91f3e2eca3 Merge branch 'main' into PM-33982/build-device-screen
# Conflicts:
#	core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt
2026-04-13 13:33:09 +01:00
Andre Rosado
360340ba87 updated copy of last active messages 2026-04-02 17:25:57 +01:00
Andre Rosado
9396a9f68d Added more tests 2026-04-02 14:51:29 +01:00
Andre Rosado
25bc0d143b Make DeviceSessionStatus when exhaustive
Change return type from ui extensions from String to Text
2026-04-02 14:51:15 +01:00
Andre Rosado
697d70f7f7 Merge branch 'main' into PM-33982/build-device-screen
# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt
#	app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt
#	app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt
#	core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt
#	ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt
#	ui/src/main/res/values/strings.xml
2026-04-01 19:33:46 +01:00
Andre Rosado
263de092bd Add device management screen 2026-04-01 17:04:28 +01:00
38 changed files with 2443 additions and 21 deletions

View File

@@ -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.
*/

View File

@@ -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(

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.auth.repository.model
import android.os.Parcelable
import com.bitwarden.network.model.DeviceType
import kotlinx.parcelize.Parcelize
import java.time.Instant
/**
* Domain model for a device registered to the current user.
*
* @property id The unique identifier of the device.
* @property name The name of the device.
* @property identifier The unique device install identifier of the device.
* @property type The type of the device.
* @property isTrusted Whether this device is trusted.
* @property creationDate The date and time on which this device was created.
* @property lastActivityDate The date and time of the device's last activity, if available.
* @property pendingAuthRequest The pending auth request for this device, if any.
* @property isCurrentDevice If this is the current device being used.
*/
@Parcelize
data class DeviceInfo(
val id: String,
val name: String,
val identifier: String,
val type: DeviceType,
val isTrusted: Boolean,
val creationDate: Instant,
val lastActivityDate: Instant?,
val pendingAuthRequest: DevicePendingAuthRequest?,
val isCurrentDevice: Boolean,
) : Parcelable

View File

@@ -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

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of retrieving all devices registered to the current user.
*/
sealed class GetDevicesResult {
/**
* Contains the list of [DeviceInfo] for the current user's registered devices.
*/
data class Success(val devices: List<DeviceInfo>) : GetDevicesResult()
/**
* There was an error retrieving the devices.
*/
data object Error : GetDevicesResult()
}

View File

@@ -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,
)

View File

@@ -120,6 +120,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToImportLogins: () -> Unit,
onNavigateToImportItems: () -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToManageDevices: () -> Unit,
) {
navigation<SettingsGraphRoute>(
startDestination = SettingsRoute.Standard,
@@ -147,6 +148,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
onNavigateToManageDevices = onNavigateToManageDevices,
)
appearanceDestination(
isPreAuth = false,

View File

@@ -20,6 +20,7 @@ fun NavGraphBuilder.accountSecurityDestination(
onNavigateToDeleteAccount: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToManageDevices: () -> Unit,
) {
composableWithPushTransitions<AccountSecurityRoute> {
AccountSecurityScreen(
@@ -27,6 +28,7 @@ fun NavGraphBuilder.accountSecurityDestination(
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
onNavigateToManageDevices = onNavigateToManageDevices,
)
}
}

View File

@@ -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()

View File

@@ -4,6 +4,7 @@ import android.os.Build
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
@@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@@ -51,6 +53,7 @@ class AccountSecurityViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val environmentRepository: EnvironmentRepository,
private val firstTimeActionManager: FirstTimeActionManager,
featureFlagManager: FeatureFlagManager,
policyManager: PolicyManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
@@ -65,6 +68,7 @@ class AccountSecurityViewModel @Inject constructor(
authRepository.isBiometricIntegrityValid(userId = userId),
isUnlockWithPasswordEnabled = userState.activeAccount.hasMasterPassword,
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
isManageDevicesEnabled = featureFlagManager.getFeatureFlag(FlagKey.ManageDevices),
shouldShowEnableAuthenticatorSync = isBuildVersionAtLeast(Build.VERSION_CODES.S),
userId = userId,
vaultTimeout = settingsRepository.vaultTimeout,
@@ -139,6 +143,12 @@ class AccountSecurityViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.ManageDevices)
.map { AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch {
trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
@@ -160,6 +170,7 @@ class AccountSecurityViewModel @Inject constructor(
AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick()
AccountSecurityAction.LockNowClick -> handleLockNowClick()
AccountSecurityAction.LogoutClick -> handleLogoutClick()
AccountSecurityAction.ManageDevicesClick -> handleManageDevicesClick()
AccountSecurityAction.PendingLoginRequestsClick -> handlePendingLoginRequestsClick()
is AccountSecurityAction.VaultTimeoutTypeSelect -> handleVaultTimeoutTypeSelect(action)
is AccountSecurityAction.CustomVaultTimeoutSelect -> handleCustomVaultTimeoutSelect(action)
@@ -353,6 +364,10 @@ class AccountSecurityViewModel @Inject constructor(
dismissUnlockNotificationBadge()
}
private fun handleManageDevicesClick() {
sendEvent(AccountSecurityEvent.NavigateToManageDevices)
}
private fun handleInternalAction(action: AccountSecurityAction.Internal) {
when (action) {
is AccountSecurityAction.Internal.BiometricsKeyResultReceive -> {
@@ -382,6 +397,10 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.Internal.RemovePinPolicyUpdateReceive -> {
handleRemovePinPolicyUpdate(action)
}
is AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive -> {
handleManageDevicesFlagUpdateReceive(action)
}
}
}
@@ -494,6 +513,12 @@ class AccountSecurityViewModel @Inject constructor(
settingsRepository.vaultTimeoutAction = vaultTimeoutAction
}
private fun handleManageDevicesFlagUpdateReceive(
action: AccountSecurityAction.Internal.ManageDevicesFlagUpdateReceive,
) {
mutableStateFlow.update { it.copy(isManageDevicesEnabled = action.isEnabled) }
}
private fun dismissUnlockNotificationBadge() {
if (!state.shouldShowUnlockActionCard) return
firstTimeActionManager.storeShowUnlockSettingBadge(
@@ -513,6 +538,7 @@ data class AccountSecurityState(
val isUnlockWithBiometricsEnabled: Boolean,
val isUnlockWithPasswordEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean,
val isManageDevicesEnabled: Boolean,
val shouldShowEnableAuthenticatorSync: Boolean,
val userId: String,
val vaultTimeout: VaultTimeout,
@@ -692,6 +718,11 @@ sealed class AccountSecurityEvent {
* Navigate to the setup unlock screen.
*/
data object NavigateToSetupUnlockScreen : AccountSecurityEvent()
/**
* Navigate to the Manage Devices screen.
*/
data object NavigateToManageDevices : AccountSecurityEvent()
}
/**
@@ -756,6 +787,11 @@ sealed class AccountSecurityAction {
*/
data object LogoutClick : AccountSecurityAction()
/**
* User clicked manage devices.
*/
data object ManageDevicesClick : AccountSecurityAction()
/**
* User clicked pending login requests.
*/
@@ -872,5 +908,12 @@ sealed class AccountSecurityAction {
data class PinProtectedLockUpdate(
val isPinProtected: Boolean,
) : Internal()
/**
* The manage devices feature flag has been updated.
*/
data class ManageDevicesFlagUpdateReceive(
val isEnabled: Boolean,
) : Internal()
}
}

View File

@@ -0,0 +1,37 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the manage devices screen.
*/
@Serializable
data object ManageDevicesRoute
/**
* Add manage devices destinations to the nav graph.
*/
fun NavGraphBuilder.manageDevicesDestination(
onNavigateBack: () -> Unit,
onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit,
) {
composableWithSlideTransitions<ManageDevicesRoute> {
ManageDevicesScreen(
onNavigateBack = onNavigateBack,
onNavigateToLoginApproval = onNavigateToLoginApproval,
)
}
}
/**
* Navigate to the Manage Devices screen.
*/
fun NavController.navigateToManageDevices(
navOptions: NavOptions? = null,
) {
this.navigate(route = ManageDevicesRoute, navOptions = navOptions)
}

View File

@@ -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())
}
}

View File

@@ -0,0 +1,472 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
import android.os.Build
import android.os.Parcelable
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.core.util.isOverFiveMinutesOld
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.util.Text
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.readableDeviceTypeName
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.toLastActivityLabel
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.format.FormatStyle
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the Manage Devices screen.
*/
@Suppress("LongParameterList")
@HiltViewModel
class ManageDevicesViewModel @Inject constructor(
private val clock: Clock,
private val authRepository: AuthRepository,
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
settingsRepository: SettingsRepository,
buildInfoManager: BuildInfoManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ManageDevicesState, ManageDevicesEvent, ManageDevicesAction>(
initialState = savedStateHandle[KEY_STATE] ?: ManageDevicesState(
authRequests = persistentListOf(),
devices = persistentListOf(),
viewState = ManageDevicesState.ViewState.Loading,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
isRefreshing = false,
internalHideBottomSheet = false,
isFdroid = buildInfoManager.isFdroid,
devicesLoaded = false,
authRequestsLoaded = false,
),
) {
private var authJob: Job = Job().apply { complete() }
init {
updateAuthRequestList()
fetchAllDevices()
settingsRepository
.getPullToRefreshEnabledFlow()
.map { ManageDevicesAction.Internal.PullToRefreshEnableReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.LOGIN_APPROVAL)
.map { ManageDevicesAction.Internal.SnackbarDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ManageDevicesAction) {
when (action) {
ManageDevicesAction.CloseClick -> handleCloseClicked()
ManageDevicesAction.HideBottomSheet -> handleHideBottomSheet()
ManageDevicesAction.LifecycleResume -> handleOnLifecycleResumed()
ManageDevicesAction.RefreshPull -> handleRefreshPull()
is ManageDevicesAction.PendingRequestRowClick -> {
handlePendingRequestRowClicked(action)
}
is ManageDevicesAction.Internal -> handleInternalAction(action)
}
}
private fun handleCloseClicked() {
sendEvent(ManageDevicesEvent.NavigateBack)
}
private fun handleHideBottomSheet() {
mutableStateFlow.update { it.copy(internalHideBottomSheet = true) }
}
private fun handleOnLifecycleResumed() {
updateAuthRequestList()
}
private fun handleRefreshPull() {
val shouldRefetchDevices = !state.devicesLoaded
mutableStateFlow.update {
it.copy(
isRefreshing = true,
authRequestsLoaded = false,
)
}
updateAuthRequestList()
if (shouldRefetchDevices) {
fetchAllDevices()
}
}
private fun handlePendingRequestRowClicked(
action: ManageDevicesAction.PendingRequestRowClick,
) {
sendEvent(ManageDevicesEvent.NavigateToLoginApproval(action.fingerprint))
}
private fun handleInternalAction(action: ManageDevicesAction.Internal) {
when (action) {
is ManageDevicesAction.Internal.PullToRefreshEnableReceive -> {
handlePullToRefreshEnableReceive(action)
}
is ManageDevicesAction.Internal.SnackbarDataReceive -> {
handleSnackbarDataReceive(action)
}
is ManageDevicesAction.Internal.GetDevicesResultReceive -> {
handleGetDevicesResultReceived(action)
}
is ManageDevicesAction.Internal.AuthRequestsResultReceive -> {
handleAuthRequestsResultReceived(action)
}
}
}
private fun handlePullToRefreshEnableReceive(
action: ManageDevicesAction.Internal.PullToRefreshEnableReceive,
) {
mutableStateFlow.update {
it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled)
}
}
private fun handleSnackbarDataReceive(
action: ManageDevicesAction.Internal.SnackbarDataReceive,
) {
sendEvent(ManageDevicesEvent.ShowSnackbar(action.data))
}
private fun updateAuthRequestList() {
authJob.cancel()
authJob = authRepository
.getAuthRequestsWithUpdates()
.map { ManageDevicesAction.Internal.AuthRequestsResultReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
private fun fetchAllDevices() {
viewModelScope.launch {
sendAction(
ManageDevicesAction.Internal.GetDevicesResultReceive(
devicesResult = authRepository.getDevices(),
),
)
}
}
private fun handleAuthRequestsResultReceived(
action: ManageDevicesAction.Internal.AuthRequestsResultReceive,
) {
val filteredRequests = when (val result = action.authRequestsUpdatesResult) {
is AuthRequestsUpdatesResult.Update -> {
result.authRequests.filterRespondedAndExpired(clock = clock)
}
is AuthRequestsUpdatesResult.Error -> emptyList()
}
mutableStateFlow.update {
it.copy(
authRequests = filteredRequests.toImmutableList(),
authRequestsLoaded = true,
isRefreshing = if (state.devicesLoaded) false else it.isRefreshing,
)
}
if (state.devicesLoaded) {
updateContentWithCurrentData()
}
}
private fun handleGetDevicesResultReceived(
action: ManageDevicesAction.Internal.GetDevicesResultReceive,
) {
val devicesResult = action.devicesResult as? GetDevicesResult.Success
?: run {
mutableStateFlow.update {
it.copy(viewState = ManageDevicesState.ViewState.Error, isRefreshing = false)
}
return
}
mutableStateFlow.update {
it.copy(
devices = devicesResult.devices.toImmutableList(),
devicesLoaded = true,
isRefreshing = if (state.authRequestsLoaded) false else it.isRefreshing,
)
}
if (state.authRequestsLoaded) {
updateContentWithCurrentData()
}
}
private fun updateContentWithCurrentData() {
val authRequestMap = state.authRequests.associateBy { it.id }
val items = state.devices
.sortedWith(
compareBy<DeviceInfo> { device ->
val matchingRequest = device.pendingAuthRequest?.let { authRequestMap[it.id] }
when {
device.isCurrentDevice -> 0
matchingRequest != null -> 1
else -> 2
}
}
.thenByDescending { it.lastActivityDate }
.thenByDescending { it.creationDate },
)
.map { device ->
val matchingRequest = device.pendingAuthRequest?.let { authRequestMap[it.id] }
val status = when {
device.isCurrentDevice -> DeviceSessionStatus.Current
matchingRequest != null -> DeviceSessionStatus.Pending
else -> DeviceSessionStatus.None
}
ManageDevicesState.ViewState.Content.DeviceItem(
id = device.id,
name = device.name,
typeName = device.type.readableDeviceTypeName,
isTrusted = device.isTrusted,
firstLoginDate = device.creationDate.toFormattedDateTimeStyle(
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.MEDIUM,
clock = clock,
),
lastActivityLabel = device.lastActivityDate?.toLastActivityLabel(
clock = clock,
),
status = status,
fingerprintPhrase = matchingRequest?.fingerprint,
)
}
mutableStateFlow.update {
it.copy(viewState = ManageDevicesState.ViewState.Content(items = items))
}
}
}
/**
* Models state for the Manage Devices screen.
*/
@Parcelize
data class ManageDevicesState(
val authRequests: ImmutableList<AuthRequest>,
val devices: ImmutableList<DeviceInfo>,
val viewState: ViewState,
private val isPullToRefreshSettingEnabled: Boolean,
val isRefreshing: Boolean,
private val internalHideBottomSheet: Boolean,
private val isFdroid: Boolean,
val devicesLoaded: Boolean,
val authRequestsLoaded: Boolean,
) : Parcelable {
/**
* Indicates that the bottom sheet should be hidden.
*/
@get:ChecksSdkIntAtLeast(parameter = Build.VERSION_CODES.TIRAMISU)
val hideBottomSheet: Boolean
get() = internalHideBottomSheet &&
!isFdroid &&
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
/**
* Indicates that the pull-to-refresh should be enabled in the UI.
*/
val isPullToRefreshEnabled: Boolean
get() = isPullToRefreshSettingEnabled && viewState.isPullToRefreshEnabled
/**
* Represents the specific view states for the [ManageDevicesScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* Indicates the pull-to-refresh feature should be available during the current state.
*/
abstract val isPullToRefreshEnabled: Boolean
/**
* Content state for the [ManageDevicesScreen] listing device items.
*/
@Parcelize
data class Content(
val items: List<DeviceItem>,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
/**
* Models the data for a registered device, optionally with a pending auth request.
*/
@Parcelize
data class DeviceItem(
val id: String,
val name: String,
val typeName: Text,
val isTrusted: Boolean,
val firstLoginDate: String,
val lastActivityLabel: Text?,
val status: DeviceSessionStatus,
val fingerprintPhrase: String?,
) : Parcelable
}
/**
* Represents a state where the [ManageDevicesScreen] is unable to display data due to an
* error retrieving it.
*/
@Parcelize
data object Error : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
/**
* Loading state for the [ManageDevicesScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = false
}
}
}
/**
* Models events for the Manage Devices screen.
*/
sealed class ManageDevicesEvent {
/**
* Navigates back.
*/
data object NavigateBack : ManageDevicesEvent()
/**
* Navigates to the Login Approval screen with the given request ID.
*/
data class NavigateToLoginApproval(
val fingerprint: String,
) : ManageDevicesEvent()
/**
* Show a snackbar to the user.
*/
data class ShowSnackbar(
val data: BitwardenSnackbarData,
) : ManageDevicesEvent(), BackgroundEvent
}
/**
* Models actions for the Manage Devices screen.
*/
sealed class ManageDevicesAction {
/**
* The user has clicked the close button.
*/
data object CloseClick : ManageDevicesAction()
/**
* The user has dismissed the bottom sheet.
*/
data object HideBottomSheet : ManageDevicesAction()
/**
* The screen has been re-opened and should be updated.
*/
data object LifecycleResume : ManageDevicesAction()
/**
* The user has clicked one of the pending request rows.
*/
data class PendingRequestRowClick(
val fingerprint: String,
) : ManageDevicesAction()
/**
* User has triggered a pull to refresh.
*/
data object RefreshPull : ManageDevicesAction()
/**
* Models actions sent by the view model itself.
*/
sealed class Internal : ManageDevicesAction() {
/**
* Indicates that the pull to refresh feature toggle has changed.
*/
data class PullToRefreshEnableReceive(
val isPullToRefreshEnabled: Boolean,
) : Internal()
/**
* Indicates that a snackbar data was received.
*/
data class SnackbarDataReceive(
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the get devices has been received.
*/
data class GetDevicesResultReceive(
val devicesResult: GetDevicesResult,
) : Internal()
/**
* Indicates that an auth requests update has been received.
*/
data class AuthRequestsResultReceive(
val authRequestsUpdatesResult: AuthRequestsUpdatesResult,
) : Internal()
}
}
/**
* Represents the session status of a registered device.
*/
enum class DeviceSessionStatus {
Current,
Pending,
None,
}
/**
* Filters out [AuthRequest]s that match one of the following criteria:
* * The request has been approved.
* * The request has been declined (indicated by it not being approved & having a responseDate).
* * The request has expired (it is at least 5 minutes old).
*/
private fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock) =
filterNot { request ->
request.requestApproved ||
request.responseDate != null ||
request.creationDate.isOverFiveMinutesOld(clock)
}

View File

@@ -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()
}

View File

@@ -0,0 +1,43 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
import com.bitwarden.network.model.DeviceType
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
/**
* Converts a [DeviceType] to a human-readable display name.
* Returns e.g. "Mobile - Android", "Extension - Chrome", "Desktop - Windows".
*/
@Suppress("CyclomaticComplexMethod")
val DeviceType.readableDeviceTypeName: Text
get() = when (this) {
DeviceType.ANDROID -> BitwardenString.mobile_platform.asText("Android")
DeviceType.IOS -> BitwardenString.mobile_platform.asText("iOS")
DeviceType.CHROME_EXTENSION -> BitwardenString.extension_platform.asText("Chrome")
DeviceType.FIREFOX_EXTENSION -> BitwardenString.extension_platform.asText("Firefox")
DeviceType.OPERA_EXTENSION -> BitwardenString.extension_platform.asText("Opera")
DeviceType.EDGE_EXTENSION -> BitwardenString.extension_platform.asText("Edge")
DeviceType.WINDOWS_DESKTOP -> BitwardenString.desktop_platform.asText("Windows")
DeviceType.MAC_OS_DESKTOP -> BitwardenString.desktop_platform.asText("MacOS")
DeviceType.LINUX_DESKTOP -> BitwardenString.desktop_platform.asText("Linux")
DeviceType.CHROME_BROWSER -> BitwardenString.web_platform.asText("Chrome")
DeviceType.FIREFOX_BROWSER -> BitwardenString.web_platform.asText("Firefox")
DeviceType.OPERA_BROWSER -> BitwardenString.web_platform.asText("Opera")
DeviceType.EDGE_BROWSER -> BitwardenString.web_platform.asText("Edge")
DeviceType.IE_BROWSER -> BitwardenString.web_platform.asText("IE")
DeviceType.UNKNOWN_BROWSER -> BitwardenString.web_platform.asText("Unknown")
DeviceType.ANDROID_AMAZON -> BitwardenString.mobile_platform.asText("Amazon")
DeviceType.UWP -> BitwardenString.desktop_platform.asText("Windows UWP")
DeviceType.SAFARI_BROWSER -> BitwardenString.web_platform.asText("Safari")
DeviceType.VIVALDI_BROWSER -> BitwardenString.web_platform.asText("Vivaldi")
DeviceType.VIVALDI_EXTENSION -> BitwardenString.extension_platform.asText("Vivaldi")
DeviceType.SAFARI_EXTENSION -> BitwardenString.extension_platform.asText("Safari")
DeviceType.SDK -> BitwardenString.sdk.asText()
DeviceType.SERVER -> BitwardenString.server.asText()
DeviceType.WINDOWS_CLI -> BitwardenString.cli_platform.asText("Windows")
DeviceType.MAC_OS_CLI -> BitwardenString.cli_platform.asText("MacOS")
DeviceType.LINUX_CLI -> BitwardenString.cli_platform.asText("Linux")
DeviceType.DUCK_DUCK_GO_BROWSER -> BitwardenString.extension_platform.asText("DuckDuckGo")
DeviceType.UNKNOWN -> BitwardenString.unknown_device.asText()
}

View File

@@ -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()

View File

@@ -52,6 +52,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToImportLogins: () -> Unit,
onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit,
onNavigateToAboutPrivilegedApps: () -> Unit,
onNavigateToManageDevices: () -> Unit,
onNavigateToPlan: () -> Unit,
) {
composableWithStayTransitions<VaultUnlockedNavbarRoute> {
@@ -76,6 +77,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps,
onNavigateToManageDevices = onNavigateToManageDevices,
onNavigateToPlan = onNavigateToPlan,
)
}

View File

@@ -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,
)
}
}

View File

@@ -31,6 +31,9 @@ import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.ConfigResponseJson
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.DeviceResponseJson
import com.bitwarden.network.model.DeviceType
import com.bitwarden.network.model.DevicesResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
import com.bitwarden.network.model.KdfJson
@@ -98,7 +101,9 @@ import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorR
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@@ -6874,6 +6879,58 @@ class AuthRepositoryTest {
assertEquals(KnownDeviceResult.Success(isKnownDevice), result)
}
@Test
fun `getDevices should return Error when service returns failure`() = runTest {
val error = Throwable("Fail!")
coEvery { devicesService.getDevices() } returns error.asFailure()
val result = repository.getDevices()
coVerify(exactly = 1) { devicesService.getDevices() }
assertEquals(GetDevicesResult.Error, result)
}
@Test
fun `getDevices should return Success when service returns success`() = runTest {
val deviceJson = DeviceResponseJson(
id = "deviceId",
name = "Test Device",
identifier = "deviceIdentifier",
type = DeviceType.ANDROID,
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
lastActivityDate = null,
isTrusted = false,
encryptedUserKey = null,
encryptedPublicKey = null,
devicePendingAuthRequest = null,
)
val devicesResponse = DevicesResponseJson(devices = listOf(deviceJson))
coEvery { devicesService.getDevices() } returns devicesResponse.asSuccess()
val result = repository.getDevices()
coVerify(exactly = 1) { devicesService.getDevices() }
assertEquals(
GetDevicesResult.Success(
devices = listOf(
DeviceInfo(
id = "deviceId",
name = "Test Device",
// identifier "deviceIdentifier" != uniqueAppId "testUniqueAppId"
identifier = "deviceIdentifier",
type = DeviceType.ANDROID,
isTrusted = false,
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
lastActivityDate = null,
pendingAuthRequest = null,
isCurrentDevice = false,
),
),
),
result,
)
}
@Test
fun `getPasswordBreachCount should return failure when service returns failure`() = runTest {
val password = "password"

View File

@@ -53,6 +53,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
private var onNavigateToDeleteAccountCalled = false
private var onNavigateToPendingRequestsCalled = false
private var onNavigateToUnlockSetupScreenCalled = false
private var onNavigateToManageDevicesCalled = false
private val intentManager = mockk<IntentManager> {
every { launchUri(any()) } just runs
@@ -93,6 +94,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true },
onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true },
onNavigateToSetupUnlockScreen = { onNavigateToUnlockSetupScreenCalled = true },
onNavigateToManageDevices = { onNavigateToManageDevicesCalled = true },
viewModel = viewModel,
)
}
@@ -1744,6 +1746,54 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToSetupUnlockScreen)
assertTrue(onNavigateToUnlockSetupScreenCalled)
}
@Test
fun `on NavigateToManageDevices event should call onNavigateToManageDevices`() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToManageDevices)
assertTrue(onNavigateToManageDevicesCalled)
}
@Test
fun `manage devices row should be visible when isManageDevicesEnabled is true`() {
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
composeTestRule
.onNodeWithText("Manage devices")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `manage devices row should not be visible when isManageDevicesEnabled is false`() {
composeTestRule
.onNodeWithText("Manage devices")
.assertDoesNotExist()
}
@Test
fun `pending login requests row should be visible when isManageDevicesEnabled is false`() {
composeTestRule
.onNodeWithText("Pending login requests")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `pending login requests row should not be visible when isManageDevicesEnabled is true`() {
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
composeTestRule
.onNodeWithText("Pending login requests")
.assertDoesNotExist()
}
@Test
fun `on manage devices click should send ManageDevicesClick action`() {
mutableStateFlow.update { it.copy(isManageDevicesEnabled = true) }
composeTestRule
.onNodeWithText("Manage devices")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.ManageDevicesClick) }
}
}
private val CIPHER = mockk<Cipher>()
@@ -1755,6 +1805,7 @@ private val DEFAULT_STATE = AccountSecurityState(
isUnlockWithBiometricsEnabled = false,
isUnlockWithPasswordEnabled = true,
isUnlockWithPinEnabled = false,
isManageDevicesEnabled = false,
userId = USER_ID,
shouldShowEnableAuthenticatorSync = false,
vaultTimeout = VaultTimeout.ThirtyMinutes,

View File

@@ -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,

View File

@@ -0,0 +1,247 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.advanceTimeByAndRunCurrent
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ManageDevicesScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private var navigateToLoginApprovalFingerprint: String? = null
private val mutableEventFlow = bufferedMutableSharedFlow<ManageDevicesEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ManageDevicesViewModel> {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
every { trySendAction(any()) } just runs
}
private val permissionsManager = FakePermissionManager().apply {
checkPermissionResult = false
shouldShowRequestRationale = false
}
@Before
fun setUp() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
setContent(
permissionsManager = permissionsManager,
) {
ManageDevicesScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLoginApproval = { fingerprint ->
navigateToLoginApprovalFingerprint = fingerprint
},
viewModel = viewModel,
)
}
}
@After
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast)
}
@Test
fun `on NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ManageDevicesEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToLoginApproval should call onNavigateToLoginApproval with fingerprint`() {
val fingerprint = "mock-fingerprint"
mutableEventFlow.tryEmit(ManageDevicesEvent.NavigateToLoginApproval(fingerprint))
assertEquals(fingerprint, navigateToLoginApprovalFingerprint)
}
@Test
fun `on ShowSnackbar event should display snackbar message`() {
val message = "Test snackbar message"
val data = BitwardenSnackbarData(message = message.asText())
composeTestRule.onNodeWithText(message).assertDoesNotExist()
mutableEventFlow.tryEmit(ManageDevicesEvent.ShowSnackbar(data))
composeTestRule.onNodeWithText(message).assertIsDisplayed()
}
@Test
fun `on close button click should send CloseClick action`() {
// Hide bottom sheet so only the AppBar close button exists
mutableStateFlow.value = DEFAULT_STATE.copy(internalHideBottomSheet = true)
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify { viewModel.trySendAction(ManageDevicesAction.CloseClick) }
}
@Test
fun `loading state should display loading content`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Loading,
)
// Loading spinner is shown no device cells
composeTestRule.onNodeWithTag("LoginRequestCell").assertDoesNotExist()
}
@Test
fun `error state should display error message`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Error,
internalHideBottomSheet = true,
)
composeTestRule
.onNodeWithText(
"We were unable to process your request. " +
"Please try again or contact us.",
)
.assertIsDisplayed()
}
@Test
fun `content state with current session device should display current session indicator`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
DEFAULT_DEVICE_ITEM.copy(status = DeviceSessionStatus.Current),
),
),
)
composeTestRule.onNodeWithText("Current session").assertIsDisplayed()
}
@Test
fun `content state with trusted device should display trusted label`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
DEFAULT_DEVICE_ITEM.copy(
status = DeviceSessionStatus.None,
isTrusted = true,
),
),
),
)
composeTestRule.onNodeWithText("Trusted").assertIsDisplayed()
}
@Test
fun `content state with pending device should display pending request indicator`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
DEFAULT_DEVICE_ITEM.copy(
status = DeviceSessionStatus.Pending,
fingerprintPhrase = "mock-fingerprint",
),
),
),
)
composeTestRule.onNodeWithText("Pending request").assertIsDisplayed()
}
@Test
fun `clicking pending request row should send PendingRequestRowClick action`() {
val fingerprint = "test-fingerprint"
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
DEFAULT_DEVICE_ITEM.copy(
status = DeviceSessionStatus.Pending,
fingerprintPhrase = fingerprint,
),
),
),
)
composeTestRule
.onNodeWithTag("LoginRequestCell")
.performClick()
verify { viewModel.trySendAction(ManageDevicesAction.PendingRequestRowClick(fingerprint)) }
}
@Test
fun `on skip for now click should send HideBottomSheet action`() {
composeTestRule
.onNodeWithText("Skip for now")
.performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick)
dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L)
verify { viewModel.trySendAction(ManageDevicesAction.HideBottomSheet) }
}
@Test
fun `bottom sheet should not show when permission already granted`() {
permissionsManager.checkPermissionResult = true
mutableStateFlow.value = DEFAULT_STATE.copy(devicesLoaded = true)
composeTestRule.onNodeWithText("Skip for now").assertDoesNotExist()
}
@Test
fun `content state should display device type name`() {
val typeName = "Mobile - Android"
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
DEFAULT_DEVICE_ITEM.copy(typeName = typeName.asText()),
),
),
)
composeTestRule.onNodeWithText(typeName).assertIsDisplayed()
}
@Test
fun `on lifecycle resume should send LifecycleResume action`() = runTest {
// The LifecycleEventEffect fires ON_RESUME - verify it was called on setup
verify { viewModel.trySendAction(ManageDevicesAction.LifecycleResume) }
}
}
private val DEFAULT_DEVICE_ITEM = ManageDevicesState.ViewState.Content.DeviceItem(
id = "device-1",
name = "Test Device",
typeName = "Mobile - Android".asText(),
isTrusted = false,
firstLoginDate = "Oct 27, 2023, 12:00:00 PM",
lastActivityLabel = "Active today".asText(),
status = DeviceSessionStatus.None,
fingerprintPhrase = null,
)
private val DEFAULT_STATE = ManageDevicesState(
authRequests = persistentListOf(),
devices = persistentListOf(),
viewState = ManageDevicesState.ViewState.Loading,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
internalHideBottomSheet = false,
isFdroid = false,
devicesLoaded = false,
authRequestsLoaded = false,
)

View File

@@ -0,0 +1,346 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.network.model.DeviceType
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
import com.x8bit.bitwarden.data.auth.repository.model.DevicePendingAuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.readableDeviceTypeName
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util.toLastActivityLabel
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.temporal.TemporalAccessor
class ManageDevicesViewModelTest : BaseViewModelTest() {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val mutableAuthRequestsWithUpdatesFlow =
bufferedMutableSharedFlow<AuthRequestsUpdatesResult>()
private val authRepository = mockk<AuthRepository> {
every { getAuthRequestsWithUpdates() } returns mutableAuthRequestsWithUpdatesFlow
coEvery { getDevices() } returns GetDevicesResult.Success(emptyList())
}
private val mutablePullToRefreshStateFlow = MutableStateFlow(false)
private val settingsRepository = mockk<SettingsRepository> {
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow
}
private val mutableSnackbarDataFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
every {
getSnackbarDataFlow(relay = any(), relays = anyVararg())
} returns mutableSnackbarDataFlow
}
private val buildInfoManager = mockk<BuildInfoManager> {
every { isFdroid } returns false
}
@BeforeEach
fun setUp() {
mockkStatic(TemporalAccessor::toFormattedDateTimeStyle)
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
@AfterEach
fun tearDown() {
unmockkStatic(TemporalAccessor::toFormattedDateTimeStyle)
unmockkStatic(::isBuildVersionAtLeast)
}
@Test
fun `init should make necessary network calls`() {
createViewModel()
coVerify {
authRepository.getAuthRequestsWithUpdates()
authRepository.getDevices()
}
}
@Test
fun `init should set devicesLoaded true after device fetch success`() {
val viewModel = createViewModel()
// After init with unconfined dispatcher, devices coroutine runs immediately
assertEquals(true, viewModel.stateFlow.value.devicesLoaded)
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ManageDevicesAction.CloseClick)
assertEquals(ManageDevicesEvent.NavigateBack, awaitItem())
}
}
@Test
fun `HideBottomSheet should set hideBottomSheet to true`() {
val viewModel = createViewModel()
assertFalse(viewModel.stateFlow.value.hideBottomSheet)
viewModel.trySendAction(ManageDevicesAction.HideBottomSheet)
assertTrue(viewModel.stateFlow.value.hideBottomSheet)
}
@Test
fun `LifecycleResume should re-fetch auth requests only`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(ManageDevicesAction.LifecycleResume)
// getAuthRequestsWithUpdates called twice: once on init, once on resume
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
coVerify(exactly = 1) { authRepository.getDevices() }
}
@Test
fun `RefreshPull when devices loaded should re-fetch auth requests only`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1)
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
coVerify(exactly = 1) { authRepository.getDevices() }
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `RefreshPull when devices failed should re-fetch both devices and auth requests`() =
runTest {
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1)
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
coVerify(exactly = 2) { authRepository.getDevices() }
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `PendingRequestRowClick should emit NavigateToLoginApproval`() = runTest {
val fingerprint = "mock-fingerprint"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ManageDevicesAction.PendingRequestRowClick(fingerprint))
assertEquals(
ManageDevicesEvent.NavigateToLoginApproval(fingerprint),
awaitItem(),
)
}
}
@Test
fun `when getDevices returns error should show error state`() {
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
val viewModel = createViewModel()
assertEquals(
ManageDevicesState(
authRequests = persistentListOf(),
devices = persistentListOf(),
viewState = ManageDevicesState.ViewState.Error,
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
internalHideBottomSheet = false,
isFdroid = false,
devicesLoaded = false,
authRequestsLoaded = false,
),
viewModel.stateFlow.value,
)
}
@Test
fun `AuthRequestsResultReceive with error should use empty auth request list`() {
val viewModel = createViewModel()
mutableAuthRequestsWithUpdatesFlow.tryEmit(
AuthRequestsUpdatesResult.Error(error = Throwable()),
)
assertEquals(
emptyList<AuthRequest>(),
viewModel.stateFlow.value.authRequests,
)
}
@Test
fun `updates to pull-to-refresh enabled state should update isPullToRefreshEnabled`() =
runTest {
val viewModel = createViewModel()
// Transition to Content state so isPullToRefreshEnabled can become true
mutableAuthRequestsWithUpdatesFlow.tryEmit(
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
)
viewModel.stateFlow.test {
val contentState = awaitItem()
assertTrue(contentState.viewState is ManageDevicesState.ViewState.Content)
assertFalse(contentState.isPullToRefreshEnabled)
mutablePullToRefreshStateFlow.value = true
val updatedState = awaitItem()
assertTrue(updatedState.isPullToRefreshEnabled)
mutablePullToRefreshStateFlow.value = false
val revertedState = awaitItem()
assertFalse(revertedState.isPullToRefreshEnabled)
}
}
@Test
fun `SnackbarDataReceive should emit ShowSnackbar event`() = runTest {
val data = BitwardenSnackbarData(message = "test".asText())
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableSnackbarDataFlow.tryEmit(data)
assertEquals(ManageDevicesEvent.ShowSnackbar(data), awaitItem())
}
}
@Test
fun `content state should sort devices with current first, pending second, others last`() =
runTest {
val pendingRequest = DevicePendingAuthRequest(
id = "auth-req-1",
creationDate = fixedClock.instant(),
)
val validAuthRequest = AuthRequest(
id = "auth-req-1",
publicKey = "publicKey",
platform = "Android",
ipAddress = "192.168.0.1",
key = null,
masterPasswordHash = null,
creationDate = fixedClock.instant(),
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
fingerprint = "fingerprint-phrase",
)
val currentDevice = DEFAULT_DEVICE.copy(isCurrentDevice = true)
val pendingDevice = DEFAULT_DEVICE.copy(
id = "device-pending",
pendingAuthRequest = pendingRequest,
)
val otherDevice = DEFAULT_DEVICE.copy(id = "device-other")
coEvery { authRepository.getDevices() } returns GetDevicesResult.Success(
devices = listOf(otherDevice, pendingDevice, currentDevice),
)
val viewModel = createViewModel()
mutableAuthRequestsWithUpdatesFlow.tryEmit(
AuthRequestsUpdatesResult.Update(authRequests = listOf(validAuthRequest)),
)
viewModel.stateFlow.test {
assertEquals(
ManageDevicesState(
authRequests = listOf(validAuthRequest).toImmutableList(),
devices = listOf(
otherDevice,
pendingDevice,
currentDevice,
).toImmutableList(),
viewState = ManageDevicesState.ViewState.Content(
items = listOf(
ManageDevicesState.ViewState.Content.DeviceItem(
id = currentDevice.id,
name = currentDevice.name,
typeName = currentDevice.type.readableDeviceTypeName,
isTrusted = currentDevice.isTrusted,
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
lastActivityLabel = currentDevice.lastActivityDate
?.toLastActivityLabel(clock = fixedClock),
status = DeviceSessionStatus.Current,
fingerprintPhrase = null,
),
ManageDevicesState.ViewState.Content.DeviceItem(
id = pendingDevice.id,
name = pendingDevice.name,
typeName = pendingDevice.type.readableDeviceTypeName,
isTrusted = pendingDevice.isTrusted,
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
lastActivityLabel = pendingDevice.lastActivityDate
?.toLastActivityLabel(clock = fixedClock),
status = DeviceSessionStatus.Pending,
fingerprintPhrase = validAuthRequest.fingerprint,
),
ManageDevicesState.ViewState.Content.DeviceItem(
id = otherDevice.id,
name = otherDevice.name,
typeName = otherDevice.type.readableDeviceTypeName,
isTrusted = otherDevice.isTrusted,
firstLoginDate = "Oct 27, 2023, 12:00:00\u202FPM",
lastActivityLabel = otherDevice.lastActivityDate
?.toLastActivityLabel(clock = fixedClock),
status = DeviceSessionStatus.None,
fingerprintPhrase = null,
),
),
),
isPullToRefreshSettingEnabled = false,
isRefreshing = false,
internalHideBottomSheet = false,
isFdroid = false,
devicesLoaded = true,
authRequestsLoaded = true,
),
awaitItem(),
)
}
}
private fun createViewModel(state: ManageDevicesState? = null) = ManageDevicesViewModel(
clock = fixedClock,
authRepository = authRepository,
snackbarRelayManager = snackbarRelayManager,
settingsRepository = settingsRepository,
buildInfoManager = buildInfoManager,
savedStateHandle = SavedStateHandle(mapOf("state" to state)),
)
}
private val DEFAULT_DEVICE = DeviceInfo(
id = "device-current",
name = "Test Device",
identifier = "identifier-current",
type = DeviceType.ANDROID,
isTrusted = false,
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
lastActivityDate = Instant.parse("2023-10-27T12:00:00Z"),
pendingAuthRequest = null,
isCurrentDevice = false,
)

View File

@@ -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),
)
}
}

View File

@@ -0,0 +1,48 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.managedevices.util
import com.bitwarden.network.model.DeviceType
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DynamicTest.dynamicTest
import org.junit.jupiter.api.TestFactory
class DeviceTypeExtensionsTest {
@TestFactory
fun `readableDeviceTypeName returns correct value for each known device type`() =
listOf(
DeviceType.ANDROID to BitwardenString.mobile_platform.asText("Android"),
DeviceType.IOS to BitwardenString.mobile_platform.asText("iOS"),
DeviceType.CHROME_EXTENSION to BitwardenString.extension_platform.asText("Chrome"),
DeviceType.FIREFOX_EXTENSION to BitwardenString.extension_platform.asText("Firefox"),
DeviceType.OPERA_EXTENSION to BitwardenString.extension_platform.asText("Opera"),
DeviceType.EDGE_EXTENSION to BitwardenString.extension_platform.asText("Edge"),
DeviceType.WINDOWS_DESKTOP to BitwardenString.desktop_platform.asText("Windows"),
DeviceType.MAC_OS_DESKTOP to BitwardenString.desktop_platform.asText("MacOS"),
DeviceType.LINUX_DESKTOP to BitwardenString.desktop_platform.asText("Linux"),
DeviceType.CHROME_BROWSER to BitwardenString.web_platform.asText("Chrome"),
DeviceType.FIREFOX_BROWSER to BitwardenString.web_platform.asText("Firefox"),
DeviceType.OPERA_BROWSER to BitwardenString.web_platform.asText("Opera"),
DeviceType.EDGE_BROWSER to BitwardenString.web_platform.asText("Edge"),
DeviceType.IE_BROWSER to BitwardenString.web_platform.asText("IE"),
DeviceType.UNKNOWN_BROWSER to BitwardenString.web_platform.asText("Unknown"),
DeviceType.ANDROID_AMAZON to BitwardenString.mobile_platform.asText("Amazon"),
DeviceType.UWP to BitwardenString.desktop_platform.asText("Windows UWP"),
DeviceType.SAFARI_BROWSER to BitwardenString.web_platform.asText("Safari"),
DeviceType.VIVALDI_BROWSER to BitwardenString.web_platform.asText("Vivaldi"),
DeviceType.VIVALDI_EXTENSION to BitwardenString.extension_platform.asText("Vivaldi"),
DeviceType.SAFARI_EXTENSION to BitwardenString.extension_platform.asText("Safari"),
DeviceType.SDK to BitwardenString.sdk.asText(),
DeviceType.SERVER to BitwardenString.server.asText(),
DeviceType.WINDOWS_CLI to BitwardenString.cli_platform.asText("Windows"),
DeviceType.MAC_OS_CLI to BitwardenString.cli_platform.asText("MacOS"),
DeviceType.LINUX_CLI to BitwardenString.cli_platform.asText("Linux"),
DeviceType.DUCK_DUCK_GO_BROWSER to
BitwardenString.extension_platform.asText("DuckDuckGo"),
DeviceType.UNKNOWN to BitwardenString.unknown_device.asText(),
).map { (type, expected) ->
dynamicTest("$type should return $expected") {
assertEquals(expected, type.readableDeviceTypeName)
}
}
}

View File

@@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() {
onNavigateToFlightRecorder = {},
onNavigateToRecordedLogs = {},
onNavigateToAboutPrivilegedApps = {},
onNavigateToManageDevices = {},
onNavigateToPlan = {},
)
}

View File

@@ -38,6 +38,7 @@ sealed class FlagKey<out T : Any> {
SendEmailVerification,
CardScanner,
MobilePremiumUpgrade,
ManageDevices,
AttachmentUpdates,
V2EncryptionJitPassword,
V2EncryptionKeyConnector,
@@ -129,6 +130,14 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for the Manage Devices feature.
*/
data object ManageDevices : FlagKey<Boolean>() {
override val keyName: String = "pm-4516-manage-devices"
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for Encryption V2 pertaining to JIT Password.
*/

View File

@@ -1,10 +1,12 @@
package com.bitwarden.network.api
import androidx.annotation.Keep
import com.bitwarden.network.model.DevicesResponseJson
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.model.TrustedDeviceKeysRequestJson
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
@@ -13,6 +15,9 @@ import retrofit2.http.Path
*/
@Keep
internal interface AuthenticatedDevicesApi {
@GET("/devices")
suspend fun getDevices(): NetworkResult<DevicesResponseJson>
@PUT("/devices/{appId}/keys")
suspend fun updateTrustedDeviceKeys(
@Path(value = "appId") appId: String,

View File

@@ -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,
)

View File

@@ -0,0 +1,34 @@
package com.bitwarden.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Response body for a single device registered to the current user.
*
* @property id The unique identifier of the device.
* @property name The name of the device.
* @property identifier The unique install identifier of the device.
* @property type The type of the device.
* @property creationDate The date and time on which this device was created.
* @property isTrusted Whether this device is trusted.
* @property encryptedUserKey The encrypted user key for this device, if available.
* @property encryptedPublicKey The encrypted public key for this device, if available.
* @property devicePendingAuthRequest The pending auth request for this device, if any.
*/
@Serializable
data class DeviceResponseJson(
@SerialName("id") val id: String,
@SerialName("name") val name: String,
@SerialName("identifier") val identifier: String,
@SerialName("type") val type: DeviceType,
@Contextual @SerialName("creationDate") val creationDate: Instant,
@Contextual @SerialName("lastActivityDate") val lastActivityDate: Instant?,
@SerialName("isTrusted") val isTrusted: Boolean,
@SerialName("encryptedUserKey") val encryptedUserKey: String?,
@SerialName("encryptedPublicKey") val encryptedPublicKey: String?,
@SerialName("devicePendingAuthRequest")
val devicePendingAuthRequest: DevicePendingAuthRequestJson?,
)

View File

@@ -0,0 +1,109 @@
package com.bitwarden.network.model
import androidx.annotation.Keep
import com.bitwarden.core.data.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the type of registered device as returned by the server.
*/
@Serializable(DeviceTypeSerializer::class)
enum class DeviceType {
@SerialName("0")
ANDROID,
@SerialName("1")
IOS,
@SerialName("2")
CHROME_EXTENSION,
@SerialName("3")
FIREFOX_EXTENSION,
@SerialName("4")
OPERA_EXTENSION,
@SerialName("5")
EDGE_EXTENSION,
@SerialName("6")
WINDOWS_DESKTOP,
@SerialName("7")
MAC_OS_DESKTOP,
@SerialName("8")
LINUX_DESKTOP,
@SerialName("9")
CHROME_BROWSER,
@SerialName("10")
FIREFOX_BROWSER,
@SerialName("11")
OPERA_BROWSER,
@SerialName("12")
EDGE_BROWSER,
@SerialName("13")
IE_BROWSER,
@SerialName("14")
UNKNOWN_BROWSER,
@SerialName("15")
ANDROID_AMAZON,
@SerialName("16")
UWP,
@SerialName("17")
SAFARI_BROWSER,
@SerialName("18")
VIVALDI_BROWSER,
@SerialName("19")
VIVALDI_EXTENSION,
@SerialName("20")
SAFARI_EXTENSION,
@SerialName("21")
SDK,
@SerialName("22")
SERVER,
@SerialName("23")
WINDOWS_CLI,
@SerialName("24")
MAC_OS_CLI,
@SerialName("25")
LINUX_CLI,
@SerialName("26")
DUCK_DUCK_GO_BROWSER,
/**
* Represents an unknown device type.
*
* This is used for forward compatibility to handle new device types that the client doesn't
* yet understand.
*/
@SerialName("-1")
UNKNOWN,
}
@Keep
private class DeviceTypeSerializer : BaseEnumeratedIntSerializer<DeviceType>(
className = "DeviceType",
values = DeviceType.entries.toTypedArray(),
default = DeviceType.UNKNOWN,
)

View File

@@ -0,0 +1,14 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response body for the list of devices registered to the current user.
*
* @property devices The list of devices.
*/
@Serializable
data class DevicesResponseJson(
@SerialName("data") val devices: List<DeviceResponseJson>,
)

View File

@@ -1,11 +1,17 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.DevicesResponseJson
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
/**
* Provides an API for interacting with the /devices endpoints.
*/
interface DevicesService {
/**
* Retrieves all devices registered to the current user.
*/
suspend fun getDevices(): Result<DevicesResponseJson>
/**
* Check whether this device is known (and thus whether Login with Device is available).
*/

View File

@@ -2,6 +2,7 @@ package com.bitwarden.network.service
import com.bitwarden.network.api.AuthenticatedDevicesApi
import com.bitwarden.network.api.UnauthenticatedDevicesApi
import com.bitwarden.network.model.DevicesResponseJson
import com.bitwarden.network.model.TrustedDeviceKeysRequestJson
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
import com.bitwarden.network.util.base64UrlEncode
@@ -14,6 +15,9 @@ internal class DevicesServiceImpl(
private val authenticatedDevicesApi: AuthenticatedDevicesApi,
private val unauthenticatedDevicesApi: UnauthenticatedDevicesApi,
) : DevicesService {
override suspend fun getDevices(): Result<DevicesResponseJson> =
authenticatedDevicesApi.getDevices().toResult()
override suspend fun getIsKnownDevice(
emailAddress: String,
deviceId: String,

View File

@@ -4,6 +4,9 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.AuthenticatedDevicesApi
import com.bitwarden.network.api.UnauthenticatedDevicesApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.DeviceResponseJson
import com.bitwarden.network.model.DeviceType
import com.bitwarden.network.model.DevicesResponseJson
import com.bitwarden.network.model.TrustedDeviceKeysResponseJson
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
@@ -22,6 +25,22 @@ class DevicesServiceTest : BaseServiceTest() {
unauthenticatedDevicesApi = unauthenticatedDevicesApi,
)
@Test
fun `getDevices when request response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.getDevices()
assertTrue(actual.isFailure)
}
@Test
fun `getDevices when request response is Success should return Success`() = runTest {
val response = MockResponse().setBody(GET_DEVICES_RESPONSE_JSON).setResponseCode(200)
server.enqueue(response)
val actual = service.getDevices()
assertEquals(GET_DEVICES_RESPONSE.asSuccess(), actual)
}
@Test
fun `getIsKnownDevice when request response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
@@ -65,6 +84,42 @@ class DevicesServiceTest : BaseServiceTest() {
}
}
private val GET_DEVICES_RESPONSE: DevicesResponseJson = DevicesResponseJson(
devices = listOf(
DeviceResponseJson(
id = "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
name = "Pixel 8",
identifier = "ea7c0a13-5ce4-4f96-8e17-4fc7fa54f464",
type = DeviceType.ANDROID,
creationDate = Instant.parse("2024-03-25T18:04:28.23Z"),
lastActivityDate = Instant.parse("2024-03-26T10:00:00.00Z"),
isTrusted = true,
encryptedUserKey = null,
encryptedPublicKey = null,
devicePendingAuthRequest = null,
),
),
)
private const val GET_DEVICES_RESPONSE_JSON: String = """
{
"data": [
{
"id": "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
"name": "Pixel 8",
"identifier": "ea7c0a13-5ce4-4f96-8e17-4fc7fa54f464",
"type": 0,
"creationDate": "2024-03-25T18:04:28.23Z",
"lastActivityDate": "2024-03-26T10:00:00.00Z",
"isTrusted": true,
"encryptedUserKey": null,
"encryptedPublicKey": null,
"devicePendingAuthRequest": null
}
]
}
"""
private val TRUST_DEVICE_RESPONSE: TrustedDeviceKeysResponseJson =
TrustedDeviceKeysResponseJson(
id = "0d31b6fb-d282-43c7-b614-b13e0129dbd7",

View File

@@ -32,6 +32,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.CardScanner,
FlagKey.SendEmailVerification,
FlagKey.MobilePremiumUpgrade,
FlagKey.ManageDevices,
FlagKey.AttachmentUpdates,
FlagKey.V2EncryptionJitPassword,
FlagKey.V2EncryptionKeyConnector,
@@ -90,6 +91,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.CardScanner -> stringResource(BitwardenString.scan_card)
FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification)
FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade)
FlagKey.ManageDevices -> stringResource(BitwardenString.manage_devices_flag)
FlagKey.AttachmentUpdates -> stringResource(BitwardenString.attachment_updates)
FlagKey.V2EncryptionJitPassword -> stringResource(BitwardenString.v2_encryption_jit_password)
FlagKey.V2EncryptionKeyConnector -> stringResource(BitwardenString.v2_encryption_key_connector)

View File

@@ -1235,6 +1235,25 @@ Do you want to switch to this account?</string>
<string name="continue_without_syncing">Continue without syncing</string>
<string name="external_link">External link</string>
<string name="external_link_format" comment="Used for accessibility to indicate that tapping this item will leave the app">%1$s, External link</string>
<string name="manage_devices">Manage devices</string>
<string name="mobile_platform">Mobile - %1$s</string>
<string name="extension_platform">Extension - %1$s</string>
<string name="web_platform">Web - %1$s</string>
<string name="desktop_platform">Desktop - %1$s</string>
<string name="cli_platform">CLI - %1$s</string>
<string name="sdk">SDK</string>
<string name="server">Server</string>
<string name="unknown_device">Unknown device</string>
<string name="first_login_date"><annotation emphasis="bold">First login:</annotation> <annotation arg="0">%1$s</annotation></string>
<string name="current_session">Current session</string>
<string name="pending_request">Pending request</string>
<string name="today">Today</string>
<string name="past_seven_days">Past 7 days</string>
<string name="past_fourteen_days">Past 14 days</string>
<string name="past_thirty_days">Past 30 days</string>
<string name="over_thirty_days_ago">Over 30 days ago</string>
<string name="recently_active"><annotation emphasis="bold">Recently active:</annotation> <annotation arg="0">%1$s</annotation></string>
<string name="trusted">Trusted</string>
<string name="preview_not_available_for_files">Preview unavailable for %1$s files. You can still download it to view on your device.</string>
<string name="this_file_is_too_large_to_preview">This file is too large to preview. You can still download it to view on your device.</string>
<string name="bitwarden_could_not_decrypt_this_file_so_the_preview_cannot_be_displayed">Bitwarden could not decrypt this file, so the preview cannot be displayed.</string>

View File

@@ -50,6 +50,7 @@
<string name="v2_encryption_key_connector">V2 Encryption - Key Connector</string>
<string name="v2_encryption_jit_password">V2 Encryption - JIT Password</string>
<string name="v2_encryption_password">V2 Encryption - Password</string>
<string name="manage_devices_flag">Manage devices</string>
<!-- endregion Debug Menu -->
</resources>