Skip to content

[PM-41929] fix: Update the manage devices screen when a passwordless request push is received - #7280

Open
aj-rosado wants to merge 4 commits into
mainfrom
PM-41929/manage-devices-push-update-screen
Open

[PM-41929] fix: Update the manage devices screen when a passwordless request push is received#7280
aj-rosado wants to merge 4 commits into
mainfrom
PM-41929/manage-devices-push-update-screen

Conversation

@aj-rosado

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

The Manage Devices screen only learned about pending login requests when it polled or when the
user pulled to refresh, so a request initiated while the screen was already open did not appear
until the next poll cycle.

This wires the push notification stream into the screen:

  • AuthRequestManager.getPasswordlessAuthRequestFlow() observes PushManager.passwordlessRequestFlow,
    filters to the active user (a push for another user would otherwise be hydrated with the active
    user's token), hydrates the request with its fingerprint, and emits only requests that are still
    actionable — not already approved, not declined, and under five minutes old. Requests that fail
    to hydrate are logged and dropped rather than surfaced as errors.
  • ManageDevicesViewModel collects that flow, re-reads the device list (the only source that
    reports which device owns a pending request), and merges the request into state, replacing any
    earlier copy so it cannot be listed twice. Because the refresh is not user-initiated, a failed
    device fetch leaves the screen untouched instead of replacing it with an error — polling and
    pull-to-refresh reconcile it later.

Also extracts the repeated AuthRequestsResponseJson.AuthRequestAuthRequest mapping into a
toAuthRequest(fingerprint) extension, replacing seven hand-written copies in
AuthRequestManagerImpl with no behavior change.

@aj-rosado aj-rosado added the ai-review Request a Claude code review label Aug 18, 2026
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context t:bug Change Type - Bug labels Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the full PR and, in particular, the changes since the previous review (Address PR comments and add repeated functions into extension). The push-driven update path (PushManager.passwordlessRequestFlow → active-user filter → hydrate → isActionableManageDevicesViewModel) is correct, the non-user-initiated device refresh intentionally leaves state untouched on failure, and the toAuthRequest extraction is behavior-preserving across all eight call sites. Reviewer feedback was addressed: isActionable and filterRespondedAndExpired now live in data/auth/manager/util/AuthRequestExtensions.kt with no remaining copies, toAuthRequest takes explicit publicKey/responseDate/isRequestApproved parameters with explicit this on the mapped fields, and the intermediate PasswordlessAuthRequestDevicesReceive action was collapsed into a single action populated from the flow.

Code Review Details

No findings at or above the reporting threshold.

Verification notes:

  • No dangling references to the removed PasswordlessAuthRequestDevicesReceive action or the two deleted private filterRespondedAndExpired helpers.
  • Adding PushManager to AuthRequestManagerImpl introduces no Hilt cycle — PushManagerImpl does not depend on AuthRequestManager, and AuthManagerModule already injected PushManager for other providers.
  • The active-user filter in getPasswordlessAuthRequestFlow correctly prevents hydrating another user's request with the active user's token.
  • New unit coverage exists for the active-user filter, hydration failure, and the approved/declined/expired drop paths, plus the two new extension files.

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.02%. Comparing base (38d79cb) to head (8071273).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
...warden/data/auth/manager/AuthRequestManagerImpl.kt 82.97% 0 Missing and 8 partials ⚠️
...ntsecurity/managedevices/ManageDevicesViewModel.kt 92.59% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7280      +/-   ##
==========================================
- Coverage   86.25%   86.02%   -0.23%     
==========================================
  Files         891      976      +85     
  Lines       65294    67571    +2277     
  Branches     9808     9916     +108     
==========================================
+ Hits        56320    58130    +1810     
- Misses       5472     5951     +479     
+ Partials     3502     3490      -12     
Flag Coverage Δ
app-data 17.52% <63.21%> (-0.36%) ⬇️
app-ui-auth-tools 19.19% <0.00%> (+0.43%) ⬆️
app-ui-platform 16.73% <37.17%> (+0.31%) ⬆️
app-ui-vault 27.92% <0.00%> (+0.60%) ⬆️
authenticator 6.10% <0.00%> (+<0.01%) ⬆️
lib-core-network-bridge 4.08% <0.00%> (-0.02%) ⬇️
lib-data-ui 1.20% <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 Harness.
📢 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment on lines +429 to +437
/**
* Whether this request may still be approved or declined, meaning it has not already been
* approved, not been declined (indicated by it not being approved & having a responseDate),
* and has not expired (it is under 5 minutes old).
*/
private val AuthRequest.isActionable: Boolean
get() = !requestApproved &&
responseDate == null &&
!creationDate.isOverFiveMinutesOld(clock)

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.

♻️ DEBT: isActionable is now a third copy of the approved/declined/expired predicate.

Details and fix

The same three-clause rule already exists in two places:

  • ManageDevicesViewModel.kt:537List<AuthRequest>.filterRespondedAndExpired(clock)
  • PendingRequestsViewModel.kt:401 — identical filterRespondedAndExpired(clock)

Adding a third copy here means the five-minute window and the decline detection now have to be kept in sync across three files.

Since this PR already extracts toAuthRequest into data/auth/manager/util/, consider putting the predicate there too and having both view models' filterRespondedAndExpired delegate to it:

// data/auth/manager/util/AuthRequestExtensions.kt
val AuthRequest.isActionable: Boolean
    get() = !requestApproved &&
        responseDate == null &&
        !creationDate.isOverFiveMinutesOld(clock)

(The clock would need to become a parameter, matching how filterRespondedAndExpired already takes one.)

Non-blocking — the current behavior is correct and matches the existing copies.

@aj-rosado
aj-rosado marked this pull request as ready for review August 18, 2026 17:54
@aj-rosado
aj-rosado requested review from a team and david-livefront as code owners August 18, 2026 17:54
publicKey = initialAuthRequest.publicKey,
fingerprint = initialAuthRequest.fingerprint,
)
.toAuthRequest(fingerprint = initialAuthRequest.fingerprint)

@david-livefront david-livefront Aug 18, 2026

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 just pass these values into the extension method instead of copying it.

fun AuthRequestsResponseJson.AuthRequest.toAuthRequest(
    fingerprint: String,
    publicKey: String = this.publicKey,
    responseDate: Instant = this.responseDate,
    isRequestApproved: Boolean = this.requestApproved,
): AuthRequest = AuthRequest(

fun AuthRequestsResponseJson.AuthRequest.toAuthRequest(
fingerprint: String,
): AuthRequest = AuthRequest(
id = 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.

For clarity, can you dd the explicit this to all of these.

// The device list is the only source that reports which device owns a pending request, so
// it is re-read before the new request can be rendered against its device.
viewModelScope.launch {
sendAction(

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 just call a common method from both here and from handlePasswordlessAuthRequestDevicesReceive?

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.

Am I missing something or is the PasswordlessAuthRequestDevicesReceive action only ever launched from here?

If so, why do we need to actions like this?

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.

OK, I finally go there, the authRepository.getDevices() is suspending.

All of this is fine 😄

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 simplified it a bit by moving getDevices into a map on the getPasswordlessAuthRequestFlow

@@ -399,8 +399,4 @@ sealed class PendingRequestsAction {
* * The request has expired (it is at least 5 minutes old).
*/
private fun List<AuthRequest>.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.

Looks like this function exists in 2 spots. What do you think about consolidating them in the AuthRequestExtenstions file?

init {
updateAuthRequestList()
fetchAllDevices()
observePasswordlessAuthRequests()

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 observing this separately, should getAuthRequestsWithUpdates called in updateAuthRequestList just observer the push notifications directly?

Is there any reason not to do 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.

observePasswordlessAuthRequests Will only get the authRequest returned by the push instead of the whole list

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.

Is there a reason we would not want to live update the getAuthRequestsWithUpdates flow with data from a push?

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.

no reason, was simply my choice to try to minimize the network call, although looking at it now we might benefit from getting the whole list as right now it will not update expired requests (or other new requests) and the impact would be minimal

@david-livefront david-livefront Aug 21, 2026

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.

I have approved and you are free to merge but I do agree there would be benefits to merging the push notifications into the other flow. It would make this ViewModel much simplier and improve the speed at which getAuthRequestsWithUpdates gets updates, which is good for this screen and other places in the app.

Something to consider.

* * 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).
*/
fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock): List<AuthRequest> =

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.

👍

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

Labels

ai-review Request a Claude code review app:password-manager Bitwarden Password Manager app context t:bug Change Type - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants