Skip to content

[PM-33982] feat: Add device management screen#6754

Open
aj-rosado wants to merge 23 commits intomainfrom
PM-33982/build-device-screen
Open

[PM-33982] feat: Add device management screen#6754
aj-rosado wants to merge 23 commits intomainfrom
PM-33982/build-device-screen

Conversation

@aj-rosado
Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-33982

📔 Objective

Add a new screen for device management on Android.
This new screen should display the current device, followed by the pending authorization requests and finally all devices with active sessions ordered by "last active date".

📸 Screenshots

Manage_devices_screen Account_security_options_screen

@aj-rosado aj-rosado requested review from a team and david-livefront as code owners April 1, 2026 18:27
@aj-rosado aj-rosado added the innovation Feature work related to Innovation Sprint or BEEEP projects label Apr 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 1, 2026

Logo
Checkmarx One – Scan Summary & Detailsdca152ec-1152-4aaa-866d-7e144c85a8de

Great job! No new security vulnerabilities introduced in this pull request

# 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
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature Change Type - Feature Development labels Apr 1, 2026
@aj-rosado aj-rosado added the ai-review-vnext Request a Claude code review using the vNext workflow label Apr 1, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 1, 2026

Codecov Report

❌ Patch coverage is 92.25225% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.74%. Comparing base (3845c1f) to head (446f375).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
...countsecurity/managedevices/ManageDevicesScreen.kt 92.82% 9 Missing and 9 partials ⚠️
...ntsecurity/managedevices/ManageDevicesViewModel.kt 91.84% 4 Missing and 11 partials ⚠️
...tsecurity/managedevices/ManageDevicesNavigation.kt 28.57% 5 Missing ⚠️
...th/repository/util/DeviceResponseJsonExtensions.kt 69.23% 3 Missing and 1 partial ⚠️
...tings/accountsecurity/AccountSecurityNavigation.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6754      +/-   ##
==========================================
- Coverage   85.80%   85.74%   -0.07%     
==========================================
  Files         835      845      +10     
  Lines       59391    60027     +636     
  Branches     8668     8734      +66     
==========================================
+ Hits        50963    51471     +508     
- Misses       5441     5546     +105     
- Partials     2987     3010      +23     
Flag Coverage Δ
app-data 17.42% <3.07%> (-0.13%) ⬇️
app-ui-auth-tools 19.96% <0.00%> (-0.18%) ⬇️
app-ui-platform 16.53% <89.18%> (+0.67%) ⬆️
app-ui-vault 25.44% <0.00%> (-0.23%) ⬇️
authenticator 6.54% <0.00%> (-0.12%) ⬇️
lib-core-network-bridge 4.25% <0.19%> (-0.02%) ⬇️
lib-data-ui 1.01% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

daysAgo < 7 -> BitwardenString.active_this_week
daysAgo < 14 -> BitwardenString.active_last_week
daysAgo < 30 -> BitwardenString.active_this_month
else -> BitwardenString.active_over_thirty_days_ago
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is all based on number of days in a week/month. Should it be based on the day of the week?

If today is Monday, 3 days ago is last week, not this week, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have mirrored the other clients logic but that makes sense to me 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have updated the texts so that it is clearer 🙌

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The language does match the functionality now. But don't we want it to say this week, not last seven days?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was decided to keep it simpler as this change will affect all other clients. So we are keeping the logic and adapting the texts.

* requiring rounding (unlike the JavaScript equivalent).
*/
@Suppress("MagicNumber")
fun Instant?.toLastActivityLabel(clock: Clock, resourceManager: ResourceManager): String? {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a UI layer component, can we just return a Text?

24 -> DeviceTypeEntry(BitwardenString.cli, "MacOs")
25 -> DeviceTypeEntry(BitwardenString.cli, "Linux")
26 -> DeviceTypeEntry(BitwardenString.extension, "DuckDuckGo")
else -> return resourceManager.getString(BitwardenString.unknown_device)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here, can we just return a Text

19 -> DeviceTypeEntry(BitwardenString.extension, "Vivaldi")
20 -> DeviceTypeEntry(BitwardenString.extension, "Safari")
21 -> DeviceTypeEntry(BitwardenString.sdk, "")
22 -> DeviceTypeEntry(BitwardenString.server, "")
Copy link
Copy Markdown
Collaborator

@david-livefront david-livefront Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these say something?

Also, should these platforms be translatable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other clients are returning the same data.
Regarding translations, nothing is translatable on other clients 🤔

It makes sense to me to be able to translate words like "server" or "extension"


ManageDevicesState.ViewState.Error -> BitwardenErrorContent(
message = stringResource(
id = BitwardenString.generic_error_message,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have nothing more specific than this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not, mobile was based roughly on existing Pending Auth screen and Manage Devices extension screen.
Probably will see some more design work before being approved

)
}

else -> SessionItem(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this exhaustive

Change return type from ui extensions from String to Text
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 2, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR adds a Manage Devices screen that lists registered devices with their session status (current, pending auth request, or active session), gated behind the pm-4516-manage-devices feature flag. The implementation introduces a new DeviceType enum (replacing raw Int codes) in the network layer for type-safety, refactors DeviceInfo to carry an isCurrentDevice flag computed via the unique app identifier, and adds last-activity bucket labels (today / past 7 / 14 / 30 days). Tests cover the ViewModel, navigation, screen, repository, service, and the new utility extensions.

Code Review Details

No new high-confidence findings. Iterative review by david-livefront and prior automated reviews drove substantive improvements:

  • DeviceType introduced as a serializable enum with UNKNOWN fallback for forward compatibility (DeviceType.kt).
  • Tests now compare full ManageDevicesState rather than individual fields, per reviewer feedback.
  • The previously-flagged type = 0 integer literal in DevicesServiceTest has been corrected to DeviceType.ANDROID.
  • Removed redundant getDeviceByIdentifier round-trip; isCurrentDevice is derived locally from authDiskSource.uniqueAppId.
  • DeviceTypeExtensions now uses the mobile_platform/extension_platform/web_platform/desktop_platform/cli_platform %1$s format strings instead of string concatenation.
  • The manage_devices_flag debug-menu label was moved to strings_non_localized.xml.

The hideBottomSheet composition logic in ManageDevicesScreen.kt (and the corresponding state.hideBottomSheet derivation in ManageDevicesViewModel.kt) matches the established pattern in PendingRequestsScreen.kt/PendingRequestsViewModel.kt exactly. POST_NOTIFICATIONS is implicitly granted on pre-TIRAMISU, so permissionsManager.checkPermission(...) short-circuits the bottom sheet on those API levels and on F-Droid. The previous critical finding on this code is treated as resolved by codebase-pattern parity.

daysAgo < 30 -> BitwardenString.past_thirty_days
else -> BitwardenString.over_thirty_days_ago
}
return resourceManager.getString(resId).asText()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that you are returning a Text you do not need the ResourceManager here anymore.

* Returns e.g. "Mobile - Android", "Extension - Chrome", "Desktop - Windows".
*/
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun Int.toReadableDeviceTypeName(resourceManager: ResourceManager): Text {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, the ResourceManager is unnecessary

# Conflicts:
#	core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt
Comment on lines +313 to +316
val hideBottomSheet: Boolean
get() = internalHideBottomSheet &&
!isFdroid &&
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Boolean logic is inverted -- on F-Droid builds (and pre-TIRAMISU), hideBottomSheet always returns false, so the "Skip for now" button cannot dismiss the notification bottom sheet.

Details and fix

The current AND logic requires all three conditions to be true simultaneously. When isFdroid is true, the expression !isFdroid evaluates to false, making the entire result false regardless of internalHideBottomSheet. This means:

  1. On F-Droid with API 33+: the bottom sheet shows (asking to enable push notifications that don't work on F-Droid) and cannot be dismissed.
  2. On pre-TIRAMISU: same undismissable state, though the permission check in the Screen may mask this in some cases.

The fix is to use OR logic so that any single reason to hide the sheet is sufficient:

Suggested change
val hideBottomSheet: Boolean
get() = internalHideBottomSheet &&
!isFdroid &&
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
val hideBottomSheet: Boolean
get() = internalHideBottomSheet ||
isFdroid ||
!isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)

The @ChecksSdkIntAtLeast annotation on this property should also be removed since the corrected logic no longer solely checks for a minimum SDK level.

return if (entry.platform.isNotEmpty()) {
entry.categoryResId.asText()
.concat(
" - ${entry.platform}".asText(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of concat, can we make these format args?

<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>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This managed devices string is being used in both prod code and for feature flags.

The feature flag name should be in the unlocalized file.

# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
20 -> DeviceTypeEntry(BitwardenString.extension_platform, "Safari")
21 -> DeviceTypeEntry(BitwardenString.sdk, "")
22 -> DeviceTypeEntry(BitwardenString.server, "")
23 -> DeviceTypeEntry(BitwardenString.cli_platform, "Windows")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this intermediary step?

Can we just return the correct value here?

) {
val filteredRequests = when (val result = action.authRequestsUpdatesResult) {
is AuthRequestsUpdatesResult.Update ->
result.authRequests.filterRespondedAndExpired(clock = clock)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we wrap this in curly braces.

assertEquals(DeviceSessionStatus.Pending, content.items[1].status)
assertEquals(DeviceSessionStatus.None, content.items[2].status)
assertEquals(currentDevice.id, content.items[0].id)
assertEquals(pendingDevice.id, content.items[1].id)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be comparing again the entire ManageDevicesState, not individual ids and statuses

val viewModel = createViewModel()
assertEquals(
ManageDevicesState.ViewState.Error,
viewModel.stateFlow.value.viewState,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should compare the entire thing, not just the viewSTate

timeStyle = FormatStyle.MEDIUM,
clock = fixedClock,
)
} returns "Oct 27, 2023, 12:00:00 PM"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? The instant is fixed, so it should always return the same string

fun `type 1 should return Mobile - iOS`() {
assertEquals(
"Mobile - iOS",
1.readableDeviceTypeName.toString(resources),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of resource mocking is needed, you can just compare the outputs:

assertEquals(
    BitwardenString.mobile_platform.asText("iOS"),
    1.readableDeviceTypeName,
)

# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
val device = result.devices.first()
assertEquals("deviceId", device.id)
// identifier "deviceIdentifier" != uniqueAppId "testUniqueAppId", so not current device
assertFalse(device.isCurrentDevice)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be comparing the entire result

val id: String,
val name: String,
val identifier: String,
val type: Int,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this type be enumerated?

name = name,
identifier = identifier,
type = type,
type = DeviceType.entries.firstOrNull { it.value == type } ?: DeviceType.UNKNOWN,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add the type for DeviceResponseJson too

id = "0d31b6fb-d282-43c7-b614-b13e0129dbd7",
name = "Pixel 8",
identifier = "ea7c0a13-5ce4-4f96-8e17-4fc7fa54f464",
type = 0,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: type = 0 no longer compiles after DeviceResponseJson.type was changed to DeviceType enum.

Details and fix

In commit dad449ef the type property on DeviceResponseJson was changed from Int to the new DeviceType enum, but this test fixture still passes the integer literal 0. Kotlin will not implicitly convert Int to enum, so the network module test source no longer compiles and DevicesServiceTest cannot run.

AuthRepositoryTest.kt was updated in the same commit (now uses type = DeviceType.ANDROID); this site was missed.

Suggested change
type = 0,
type = DeviceType.ANDROID,

You'll also need to add import com.bitwarden.network.model.DeviceType at the top of the file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context innovation Feature work related to Innovation Sprint or BEEEP projects t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants