[PM-40532] feat: Lock the Send deletion date when enforced by the SendControls policy - #7258
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## PM-40534-restrict-send-creation-when-one-type-allowed #7258 +/- ##
=========================================================================================
+ Coverage 84.47% 85.97% +1.50%
=========================================================================================
Files 1114 894 -220
Lines 68737 65509 -3228
Branches 9923 9808 -115
=========================================================================================
- Hits 58068 56324 -1744
+ Misses 7150 5683 -1467
+ Partials 3519 3502 -17
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the SendControls deletion-date enforcement across Code Review Details
Minor, not posted inline: the |
There was a problem hiding this comment.
Pull request overview
This PR updates the Add/Edit Send flow to respect the SendControls policy’s deletionHours enforcement by locking the deletion-date picker to the enforced window (behind the pm-31885-send-controls feature flag), while ensuring edits to existing Sends do not silently change their deletion date.
Changes:
- Adds UI helper text and disables the deletion-date chooser when an enforced deletion window is present.
- Updates
AddEditSendViewModelto default new Sends to the enforced deletion window (or 7-day default when not enforced) and to avoid shifting deletion dates for existing Sends. - Adds ViewModel and Compose UI tests covering enforcement, toggling, and enabled/disabled picker behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/src/main/res/values/strings.xml | Adds helper text for enforced deletion date messaging. |
| app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendDeletionDateChooser.kt | Locks picker and updates supporting text when deletion date is enforced. |
| app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendCustomDateChooser.kt | Locks custom picker and updates supporting text when deletion date is enforced. |
| app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt | Applies enforced deletion window for new Sends and handles enforcement changes without affecting existing Sends. |
| app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt | Threads enforced deletion window state down into content. |
| app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt | Passes enforcement state into the add/edit deletion date chooser components. |
| app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt | Adds unit tests for enforced/default deletion window behavior and enforcement toggling. |
| app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt | Adds Compose tests asserting the chooser is locked/unlocked and helper text changes under enforcement. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| val options = DeletionOption.entries.associateWith { it.text() } | ||
| val enforcedOption = enforcedDeletionHours?.let { hours -> | ||
| DeletionOption.entries.firstOrNull { | ||
| it.offsetMillis == hours.hours.inWholeMilliseconds | ||
| } |
| val enforcedOption = enforcedDeletionHours?.let { hours -> | ||
| DeletionOption.entries.firstOrNull { | ||
| it.offsetMillis == hours.hours.inWholeMilliseconds | ||
| } | ||
| } |
There was a problem hiding this comment.
❓ QUESTION: Is deletionHours guaranteed to map to one of the seven DeletionOption values?
Details
enforcedDeletionHours comes from server policy JSON as an arbitrary Int (PolicyInformation.SendControls.deletionHours). If an org sets a value outside the fixed set (1, 24, 48, 72, 168, 336, 720), enforcedOption is null, so the locked button falls back to selectedOption and displays "7 days" — while the ViewModel state has deletionDate = now + enforcedHours. The Send would then be created with a window the user was never shown, and the field is disabled so they cannot correct it.
If the admin console constrains the value to those options, this is moot. If not, one option is to display the resolved instant when no option matches, the way AddEditSendCustomDateChooser's CustomDeletionOption.Current formats originalSelection.
There was a problem hiding this comment.
Option are fixed and are rolled out to all clients when needed.
…into PM-40532-lock-deletion-date-when-enforced
| val hours = when { | ||
| enforcedDeletionHours != null -> enforcedDeletionHours.toLong() | ||
| this.enforcedDeletionHours != null -> DEFAULT_DELETION_HOURS | ||
| else -> return null |
There was a problem hiding this comment.
I am a little bit confused with this.
Does not the state already contain the enforcedDeletionHourse?
Why if it not null are we using the DEFAULT_DELETION_HOURS instead of the value that exists on the state?
There was a problem hiding this comment.
This updates the state based on the new deletion date coming from the policy. The param is the
new value, this.enforcedDeletionHours is the previous one, so that second branch only hits
when the new value is null, i.e. enforcement was just dropped, and we fall back to the DEFAULT_DELETION_HOURS. If it was never enforced we return null and leave the date alone.
Will rename the param to newEnforcedDeletionHours since the shadowing makes it confusing.
| assertEquals(ENFORCED_DELETION_HOURS, state.enforcedDeletionHours) | ||
| assertEquals(ENFORCED_DELETION_DATE, state.deletionDateOrNull()) |
| assertNull(state.enforcedDeletionHours) | ||
| assertEquals(DEFAULT_COMMON_STATE.deletionDate, state.deletionDateOrNull()) |
| assertEquals(ENFORCED_DELETION_DATE, state.deletionDateOrNull()) | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
| assertEquals(DEFAULT_COMMON_STATE.deletionDate, state.deletionDateOrNull()) | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
| assertEquals(DEFAULT_COMMON_STATE.deletionDate, awaitItem().deletionDateOrNull()) | ||
|
|
||
| mutableEffectiveSendPolicyFlow.value = | ||
| DEFAULT_EFFECTIVE_SEND_POLICY.copy(deletionHours = ENFORCED_DELETION_HOURS) | ||
|
|
||
| assertEquals(ENFORCED_DELETION_DATE, awaitItem().deletionDateOrNull()) |
| } | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
|
|
||
| @Suppress("MaxLineLength") | ||
| @Test | ||
| fun `deletion date should not change when the enforced deletion window changes in edit mode`() = |
There was a problem hiding this comment.
Use whole state on all asserts here
| } | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
|
|
||
| mutableSendControlsFlagFlow.value = true | ||
|
|
||
| assertEquals( |
| ) | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
| disableHideEmail = true, | ||
| ) | ||
|
|
||
| val updatedState = awaitItem() |
| } | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
| assertNull(updatedState.enforcedDeletionHours) | ||
| assertEquals(DEFAULT_COMMON_STATE.deletionDate, updatedState.deletionDateOrNull()) |
|
|
||
| mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY | ||
|
|
||
| assertEquals(DEFAULT_COMMON_STATE.deletionDate, awaitItem().deletionDateOrNull()) |
| } | ||
| } | ||
|
|
||
| @Suppress("MaxLineLength") |
…into PM-40532-lock-deletion-date-when-enforced # Conflicts: # app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt # app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt
…into PM-40532-lock-deletion-date-when-enforced
…into PM-40532-lock-deletion-date-when-enforced
|
|
||
| val flagOnState = createViewModel().stateFlow.value | ||
| assertEquals(ENFORCED_DELETION_STATE, flagOnState) | ||
| assertEquals(ENFORCED_DELETION_HOURS, flagOnState.enforcedDeletionHours) |
There was a problem hiding this comment.
Is not this second assert redundant as we are already checking it on assertEquals(ENFORCED_DELETION_STATE, flagOnState) ?
…into PM-40532-lock-deletion-date-when-enforced
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-40532
📔 Objective
When an org's SendControls policy sets
deletionHours, the deletion date stops being the user's to pick. The chooser on the Add/Edit Send screen is now locked to the enforced value, and the helper text under it changes to "This date is enforced by your organization" so it's clear why the field can't be changed.A new Send picks up the enforced window instead of the usual 7 days. An existing Send keeps the deletion date it was created with — editing a Send for some unrelated reason shouldn't quietly push its deletion date out.
The View screen is left alone. The ticket's technical breakdown asked for the same helper text there, but the Figma acceptance criteria says to keep showing the date as normal, and the ticket itself flags this as an open question for design (item G3). Happy to add it if design comes back the other way.
All of it sits behind the
pm-31885-send-controlsflag. With the flag off the field behaves exactly as before: editable, default 7-day window, original helper text. If the flag or the policy is switched off while the screen is open, a new Send's date falls back to the 7-day default, so the chooser and the state can't end up showing different things.Stacked on #7249.
📸 Screenshots