Compare commits

...

440 Commits

Author SHA1 Message Date
anonymous
5d5cbdd178 fix: Address code review findings and add ViewModel tests
Code review fixes:
- Remove duplicate KeyConnectorUrl branch in InitUserCryptoMethodExtensions
- Fix CollectionManagerTest createCollection calls to include organizationUserId
- Prevent vault sync from overwriting user's in-progress edits in
  CollectionAddEditViewModel (early return if already in Content state)
- Add per-collection canManage permission check before allowing edit
  navigation, based on collection manage flag and org role
- Gitignore .claude/outputs/ to exclude plan documents from commits

New tests:
- CollectionsViewModelTest: 11 tests covering navigation, state updates,
  FAB visibility based on permissions, snackbar relay, and error states
- CollectionAddEditViewModelTest: 20 tests covering create/edit/delete
  flows, name validation, dialog states, snackbar relay, and the sync
  overwrite protection fix

Updated test fixtures:
- SyncResponseProfileUtil: add organizationUserId, limitCollectionCreation,
  limitCollectionDeletion fields
2026-03-25 14:02:03 -04:00
anonymous
5dcaf6e4a8 fix: Grant creating user manage access and fix permission checks
- Add organizationUserId to SyncResponseJson and Organization domain
  model to identify the current user's org membership ID
- Include creating user with manage access in collection create request,
  matching web client behavior
- Add limitCollectionCreation/limitCollectionDeletion to org model
- Fix FAB visibility: use canManageCollections computed property that
  checks role (Owner/Admin) in addition to permissions flags, matching
  web client logic: !limitCollectionCreation || isAdmin || permissions
2026-03-25 13:08:14 -04:00
Patrick Honkonen
d0809a7c07 fix: Include access permissions in collection update request
The PUT endpoint for updating a collection requires groups and users
access permissions in the request body. Previously only the encrypted
name was sent, causing the server to reject the request with "At least
one member or group must have can manage permission."

The update flow now fetches collection details via the new /details
endpoint before sending the PUT request, echoing back existing groups,
users, and externalId. Also fixes collection edit screen passing
organizationName instead of organizationId and resolves compile errors
from new parameters across tests.
2026-03-24 16:44:16 -04:00
anonymous
27eab5570f fix: Adapt to local SDK API changes for local development
Add vaultUrl parameter to SsoCookieVendorConfig and handle new
KeyConnectorUrl variant in InitUserCryptoMethod when expressions.
These changes are required for compatibility with the latest
sdk-internal build used for local collection encryption testing.
2026-03-24 15:09:33 -04:00
anonymous
f6435a0a1e feat: Replace encryptCollection stub with real SDK call
Remove the UnsupportedOperationException stub and delegate to the
actual SDK collections().encrypt() method. Requires SDK version with
collection encryption support (not yet in published 2.0.0-5451).
2026-03-24 15:09:33 -04:00
anonymous
d3e4dc854b feat: Add collection management (create, edit, delete) to Settings > Vault
Add full CRUD support for managing collections on Android, accessible
via Settings > Vault > Collections. Collections are organization-scoped
vault items available on paid plans.

Changes include:
- Network layer: CollectionsApi, CollectionService, request/response models
- Data layer: CollectionManager with encrypt > API > disk > decrypt flow
- Permission model: expanded SyncResponseJson.Permissions and Organization
  with collection-specific permission fields
- UI: CollectionsScreen (list with org subtitles, permission-gated FAB),
  CollectionAddEditScreen (name field, save, delete with confirmation)
- Navigation: type-safe routes wired through VaultSettings entry point
- VaultDiskSource.deleteCollection and VaultSdkSource.encryptCollection stub

Note: encryptCollection is stubbed pending SDK release (SDK changes are
implemented but not yet published). Create/update will fail at runtime
until the SDK is updated.
2026-03-24 15:09:29 -04:00
David Perez
b4917ceb95 chore: Implement Folder Repo interface for Bitwarden SDK (#6691) 2026-03-24 17:39:47 +00:00
David Perez
2b69753397 PM-29871: bug: Add more accessibility callouts for external links (#6708) 2026-03-23 20:06:16 +00:00
Patrick Honkonen
c786756f5b [PM-33999] chore: Standardize casing of Premium account status references (#6707) 2026-03-23 15:33:13 +00:00
David Perez
078b4e6f1b PM-25654: feat: Preview attachment (#6675)
Co-authored-by: amrg101 <amr2018xo@gmail.com>
2026-03-23 14:07:50 +00:00
bw-ghapp[bot]
d2ca13f88b Crowdin Pull (#6705)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-23 12:39:27 +00:00
Patrick Honkonen
2e29ab389d [PM-33515] feat: Render premium upgrade banner in Vault UI (#6698) 2026-03-20 21:27:18 +00:00
David Perez
6c7348ebd4 misc: Update BitwardenButtonData for more usability (#6704) 2026-03-20 21:26:53 +00:00
David Perez
6cf15fb792 chore: Remove unused how_to_manage_my_vault string (#6702) 2026-03-20 16:27:05 +00:00
Patrick Honkonen
988a321944 [PM-33514] feat: Add premium upgrade banner visibility logic (#6696) 2026-03-20 15:58:47 +00:00
aj-rosado
044bfb1bb2 [PM-23560] bug: Added guard to ensure duplicate scan events are not fired (#6687) 2026-03-20 15:24:16 +00:00
David Perez
eab2720e3e PM-32721: bug: Add sorting to password history (#6700) 2026-03-20 14:30:08 +00:00
Patrick Honkonen
4a069e9703 [PM-33513] feat: Add checkout callback deep link handling (#6692) 2026-03-20 14:16:43 +00:00
Patrick Honkonen
12c96de168 chore: Reorganize imports in VaultViewModel (#6701) 2026-03-20 14:08:32 +00:00
David Perez
4375782b09 PM-33913: bug: Remove org event to avoid duplicate entry (#6699) 2026-03-20 13:57:45 +00:00
David Perez
e969a42eff PM-33909: bug: Check the column index before querying for 3rd party autofill data (#6697) 2026-03-19 20:47:45 +00:00
David Perez
68e2fe4dd7 PM-33907: bug: Handle exceptions thrown when querying the AutofillManager (#6695) 2026-03-19 20:25:00 +00:00
Patrick Honkonen
37907cbe0c [PM-33512] feat: Add PremiumStateManager for upgrade banner eligibility (#6690) 2026-03-19 19:32:27 +00:00
Patrick Honkonen
c1d1de27f0 [PM-33510] feat: Add Play Billing Library dependency and PlayBillingManager (#6680) 2026-03-19 18:12:03 +00:00
David Perez
be8777cb8e PM-33893: bug: Crash caused by empty credential password (#6693) 2026-03-19 17:12:59 +00:00
Patrick Honkonen
2b9e142107 [PM-33509] feat: Add BillingRepository and Hilt billing modules (#6674) 2026-03-18 19:31:50 +00:00
David Perez
685493fde0 misc: Rename the VaultDiskSource Flows (#6689) 2026-03-18 18:59:20 +00:00
Patrick Honkonen
6d04c04929 [PM-33508] feat: Add AuthenticatedBillingApi and BillingService network layer (#6668) 2026-03-18 16:23:09 +00:00
David Perez
04c3147a56 misc: Add an error message to the DownloadAttachmentResult (#6688) 2026-03-18 16:22:54 +00:00
Patrick Honkonen
44c22deb3a llm: Add /review-android command and align reviewing-changes skill with agent (#6665) 2026-03-18 06:51:03 +00:00
Patrick Honkonen
6824af48e1 llm: Clarify @Suppress("MaxLineLength") usage in testing skill (#6685) 2026-03-18 06:50:00 +00:00
David Perez
183255cbff PM-33160: Instantiate SDK client with Repositories class (#6681) 2026-03-17 20:28:14 +00:00
David Perez
9d5a82e9ea Update app to use the latest version of Kotlin (#6684) 2026-03-17 20:27:09 +00:00
David Perez
7046029a45 Update Androidx dependencies (#6683) 2026-03-17 20:26:50 +00:00
Patrick Honkonen
4ed731706c [PM-33365] feat: Add GmsManager to gate CXP features on GMS Core version (#6678) 2026-03-17 20:21:53 +00:00
Patrick Honkonen
ec3c9001cf [PM-33553] fix: Remove "Why am I seeing this?" link from cookie sync screen (#6676) 2026-03-17 16:15:45 +00:00
David Perez
7666fb82b8 misc: Add support for icons in buttons via BitwardenButtonData (#6682) 2026-03-17 16:12:48 +00:00
Álison Fernandes
fcfa647806 [PM-18892] ci: Comment linked issues when a new GitHub Release is published (#6552) 2026-03-17 14:44:56 +00:00
Patrick Honkonen
e91797f86c Revert "Update SDK to 2.0.0-5676-14521973" (#6679) 2026-03-16 19:19:00 +00:00
André Bispo
ad7dc3fb5d [PM-33356] feat: Sync when push notification policy changed is received (#6664) 2026-03-16 15:37:26 +00:00
bw-ghapp[bot]
43bd83f883 Update SDK to 2.0.0-5676-14521973 (#6615)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-03-16 15:00:54 +00:00
renovate[bot]
0b78fd0018 [deps]: Update actions/upload-artifact action to v7 (#6672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 12:24:51 +00:00
aj-rosado
6888e676dc [PM-32663] feat: Update vault migration screens (#6660) 2026-03-16 12:19:16 +00:00
renovate[bot]
c52d5efb46 [deps]: Lock file maintenance (#6673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:58:46 +00:00
renovate[bot]
4fb379911d [deps]: Update org.sonarqube to v7.2.3.7755 (#6671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:57:31 +00:00
renovate[bot]
8b5793734a [deps]: Update androidx.credentials:credentials to v1.6.0-rc02 (#6670)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:56:16 +00:00
bw-ghapp[bot]
d17617ee5a Crowdin Pull (#6669)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-16 11:53:20 +00:00
Patrick Honkonen
ae5a14e386 [PM-33511] feat: Add creationDate to UserState.Account (#6662) 2026-03-13 20:50:00 +00:00
renovate[bot]
193ec12ebd [deps]: Lock file maintenance (#6604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 19:37:15 +00:00
David Perez
53afde1509 PM-25654: Update premium dialog for attachments (#6663) 2026-03-13 18:08:40 +00:00
Patrick Honkonen
8707a8db95 llm: Capture test failures on first run in build-test-verify skill (#6661) 2026-03-13 17:51:31 +00:00
Patrick Honkonen
13c8cc08a6 [PM-33506] feat: Add PremiumStatusChanged push notification support (#6656) 2026-03-13 17:09:26 +00:00
Patrick Honkonen
85c3a1deb8 [PM-33561] debt: Wire CipherManager and cipher ViewModel error handlers (#6651) 2026-03-13 17:08:41 +00:00
Patrick Honkonen
870f15418b [PM-33394] debt: Wire FolderManager and folder ViewModel error handlers (#6653) 2026-03-13 17:07:15 +00:00
Patrick Honkonen
453fc22d57 [PM-33507] feat: Add premium upgrade banner dismissal persistence (#6657) 2026-03-13 15:52:15 +00:00
Patrick Honkonen
93a3e0af32 [PM-33560] debt: Wire SendManager and Send ViewModel error handlers (#6652) 2026-03-13 15:31:04 +00:00
Patrick Honkonen
026a348d12 [PM-33505] feat: Add MobilePremiumUpgrade feature flag (#6655) 2026-03-13 15:03:36 +00:00
David Perez
01a137e4e3 PM-29871: bug: Add external link callouts for buttons (#6648) 2026-03-13 14:55:33 +00:00
David Perez
5b965e7923 Update error state to allow for a more customizable button (#6654) 2026-03-13 14:21:46 +00:00
Patrick Honkonen
3904f24f0a [PM-33478] llm: Add android-implementer agent for autonomous development workflow (#6635) 2026-03-13 07:56:49 +00:00
Álison Fernandes
68880ff5e3 [PM-33495] ci: Remove build job to reduce Build workflows time (#6658) 2026-03-12 23:01:08 +00:00
David Perez
d9f8c3d792 PM-29869: bug: Update colorscheme to improve accessibility (#6647) 2026-03-12 18:06:54 +00:00
David Perez
8455f7f706 PM-33441: bug: Add external link callout for start registration screen (#6646) 2026-03-12 17:47:26 +00:00
Patrick Honkonen
bb46c3812f [PM-33394] fix: Surface CookieRedirectException message during sync-on-unlock (#6643) 2026-03-12 15:10:05 +00:00
Patrick Honkonen
9068307928 [PM-33394] debt: Add userFriendlyMessage extension and errorMessage to result types (#6642) 2026-03-12 13:56:49 +00:00
David Perez
04bcd35776 PM-33411: bug: Defer early navigation until lifecycle is resumed (#6638) 2026-03-11 21:26:58 +00:00
David Perez
55e65480f1 PM-33428: bug: Fix loading dialog statusbar content color (#6641) 2026-03-11 21:26:39 +00:00
Patrick Honkonen
5af4af95e4 [PM-33394] fix: Propagate CookieRedirectException error message (#6639) 2026-03-11 18:17:52 +00:00
aj-rosado
417a14fca2 [PM-29673] feat: Improved pre-polutated data on the FlightRecorder logs (#6616) 2026-03-11 14:36:43 +00:00
David Perez
44f5f614b6 PM-29871: bug: Add external link callouts (#6634) 2026-03-10 20:53:16 +00:00
David Perez
9e3360e421 PM-18596: feat: SSN field should be hidden by default (#6628) 2026-03-10 14:50:04 +00:00
David Perez
1b6b46f72e docs: Clean up kdoc issues (#6629) 2026-03-10 14:43:50 +00:00
Patrick Honkonen
6570115d9e [PM-33227] feat: Add Clear SSO Cookies button to debug menu (#6620) 2026-03-09 20:35:59 +00:00
David Perez
ee40623911 Update protobuf library (#6626) 2026-03-09 20:24:05 +00:00
Patrick Honkonen
f99eaafc67 [PM-32123] feat: Propagate informative cookie redirect error message (#6622) 2026-03-09 20:19:24 +00:00
Patrick Honkonen
77d541d033 [PM-33262] feat: Add cookie support to Glide image requests (#6627) 2026-03-09 20:18:39 +00:00
David Perez
2d7475556f PM-29861: Update overflow content description to 'More options' (#6621) 2026-03-09 19:16:34 +00:00
David Perez
e260f1d2a5 PM-29871: Add additional callouts for external links in the app (#6614) 2026-03-09 18:14:10 +00:00
David Perez
5bd15a8fca Update AGP and gradle wrapper (#6619) 2026-03-09 17:51:10 +00:00
David Perez
fa4347db96 PM-33266: Allow the VaultUnlockViewModel and VaultViewModel to safely initialize without a UserState (#6623) 2026-03-09 16:53:24 +00:00
David Perez
d88de04acb PM-26059: Remove CipherKeyEncryption feature flag (#6617) 2026-03-09 16:44:48 +00:00
David Perez
aeed96e210 Remove remember ViewModel (#6618) 2026-03-09 16:41:54 +00:00
bw-ghapp[bot]
6473d54f16 Crowdin Pull (#6625)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-09 14:44:43 +00:00
bw-ghapp[bot]
aa23d5e5dc Update SDK to 2.0.0-5451-c73f9161 (#6605)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-03-04 20:45:01 +00:00
Daniel James Smith
053ac28e38 Remove Gitter chat badge from README (#6612) 2026-03-04 17:54:54 +00:00
Patrick Honkonen
3400d5f875 llm: Add plan-android-work command and planning skills (#6597) 2026-03-04 13:35:57 +00:00
David Perez
9f274bbffa PM-33112: Avoid double announcement of BitwardenSwitch content description (#6611) 2026-03-04 00:48:15 +00:00
David Perez
cf1455a45a Add Authenticator app-lock timeout (#6609) 2026-03-03 20:14:35 +00:00
Patrick Honkonen
d0dc4200f8 [PM-21659] llm: Add workflow skills and finalize CLAUDE.md restructuring (#6575)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 06:57:57 +00:00
David Perez
8a2b46e81a Move the AppStateManager to the data module (#6593) 2026-03-02 20:53:22 +00:00
David Perez
3538ca54ca Update Compose to 2026.02.01 (#6607) 2026-03-02 19:21:24 +00:00
David Perez
5a61ba96f6 Update Firebase BOM (#6606) 2026-03-02 19:21:07 +00:00
David Perez
836233f4d5 Move FakeLifecycle to core module (#6608) 2026-03-02 17:48:20 +00:00
renovate[bot]
3b081faf65 [deps]: Update hilt to v2.59.2 (#6602)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:35:30 +00:00
renovate[bot]
61517014a7 [deps]: Update com.google.devtools.ksp to v2.3.6 (#6601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:03:34 +00:00
renovate[bot]
4a1582b1e4 [deps]: Update org.junit:junit-bom to v6.0.3 (#6603)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:03:09 +00:00
bw-ghapp[bot]
227224b6cb Crowdin Pull (#6600)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-02 15:05:12 +00:00
Patrick Honkonen
60bc6ee0ca [PM-32802] fix: 400 error when archiving/unarchiving org-owned ciphers (#6592) 2026-02-27 20:10:58 +00:00
Patrick Honkonen
e509d60af6 Replace test workflow with sharded parallel CI execution (#6582)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-02-27 18:49:47 +00:00
Patrick Honkonen
1f9390a668 [PM-32658] Add skill routing to CLAUDE.md Quick Reference (#6574)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-27 18:13:46 +00:00
Patrick Honkonen
ed1abcac5b [PM-32657] Add build-test-verify skill and extract build/test/deploy sections (#6573) 2026-02-27 16:32:02 +00:00
bw-ghapp[bot]
209e216213 Update SDK to 2.0.0-5425-a6f4a233 (#6595)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-27 15:43:26 +00:00
bw-ghapp[bot]
7bde0ce716 Update SDK to 2.0.0-5422-26e2b107 (#6569)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-27 11:34:47 +00:00
Patrick Honkonen
a517b3f970 [PM-32656] Fix implementing-android-code skill annotations and formatting (#6572)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-26 20:00:19 +00:00
Álison Fernandes
c7d173cf9a [PM-32751] ci: Fix version name output in run summary (#6585) 2026-02-26 18:56:45 +00:00
Patrick Honkonen
38f3d3d720 [PM-32714] Add cookie domain-suffix resolution and fix cloud config path exclusion (#6589) 2026-02-26 16:35:03 +00:00
David Perez
487b163d38 BWA-235: Update Authenticator to use state-based navigation for top-level navigation (#6586) 2026-02-26 15:27:49 +00:00
Patrick Honkonen
52da80e0fc [PM-32780] Disable Claude Code attribution in commits and PRs (#6588) 2026-02-26 15:16:30 +00:00
Álison Fernandes
1abb640512 [PM-32758] ci: Improve CI cache to fix GitHub runners running out of memory (#6583) 2026-02-25 22:24:48 +00:00
David Perez
64a79ff108 PM-29870: Add explicit traversal order for scaffold (#6580) 2026-02-25 18:08:20 +00:00
David Perez
fd6d32ec09 PM-31772: Simplify origin for verified sources (#6577) 2026-02-25 17:13:47 +00:00
David Perez
4ca79bb8c7 Remove unnecessary opt-in annotations (#6581) 2026-02-25 17:11:38 +00:00
Patrick Honkonen
642456f2fe [PM-32655] Extract troubleshooting guide into docs/TROUBLESHOOTING.md (#6571)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-25 16:07:10 +00:00
André Bispo
7b1b519b0d [PM-30916] bug: Create passkey myitems (#6558) 2026-02-25 15:12:35 +00:00
David Perez
d51d6c7c54 PM-29867: Fix notifications announcement (#6570) 2026-02-24 21:59:53 +00:00
Patrick Honkonen
4adb46170d [PM-32566] Refactor cookie acquisition ViewModel and simplify tests (#6564)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 16:16:26 +00:00
bw-ghapp[bot]
3360999706 Update SDK to 2.0.0-5335-7a22aa7f (#6562)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-24 11:59:16 +00:00
Patrick Honkonen
b10568a3ae Add implementing-android-code skill and deduplicate CLAUDE.md (#6534)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-24 07:09:15 +00:00
David Perez
d9f6fe97ff PM-32607: Label headers for accesibility (#6567) 2026-02-23 22:08:32 +00:00
David Perez
89f70a6b18 PM-29871: Add external links announcements (#6566) 2026-02-23 17:48:35 +00:00
David Perez
8b2aaf9c79 PM-29866: Remove redundant content description in icon buttons (#6565) 2026-02-23 17:41:13 +00:00
bw-ghapp[bot]
c9f3afa851 Crowdin Pull (#6561)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-23 12:57:50 +00:00
bw-ghapp[bot]
5ef7482fae Update SDK to 2.0.0-5302-1693d4d4 (#6549)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-23 09:42:08 +00:00
David Perez
c69f3554c6 PM-30892: Fix radio button spacing (#6559) 2026-02-20 23:15:52 +00:00
David Perez
c6b4c490ca Replace ZonedDateTime with Instant (#6554) 2026-02-20 19:02:25 +00:00
David Perez
92664b6752 Fix incorrect apostrophe (#6557) 2026-02-20 16:48:30 +00:00
Hunter Wittenborn
06284a31df [PM-32356] Fix: Use soft logout for token refresh failures to preserve account (#6545)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 21:59:30 +00:00
aj-rosado
794781213e [PM-31835] feat: add generator copy password field on send (#6508) 2026-02-19 19:50:10 +00:00
aj-rosado
d1cf808e97 [PM-31810] Added logic to gate Send auth verification behind premium (#6556) 2026-02-19 19:10:59 +00:00
Álison Fernandes
4356156aad [PM-32200] ci: Add workflow to enforce PR labels (#6530) 2026-02-19 18:32:29 +00:00
David Perez
268be4210e PM-29863: Update segmented control font (#6555) 2026-02-19 17:47:05 +00:00
aj-rosado
4ee55111f4 [PM-32149] Send email verification error dialogs (#6535) 2026-02-19 15:30:18 +00:00
Patrick Honkonen
1a6936262c [PM-32122] Add cookie acquisition navigation (#6529)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-18 18:11:43 +00:00
David Perez
6f19ae534f Clean up ColorExtensions tests (#6551) 2026-02-18 13:26:07 +00:00
Patrick Honkonen
46a8236ef7 Update RootNavScreen docs (#6553) 2026-02-18 13:24:40 +00:00
Patrick Honkonen
f6f630ff8c [PM-32121] Add CookieAcquisition screen and ViewModel (#6523)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 20:44:44 +00:00
David Perez
bd0640e5b4 PM-32353: Archive and Unarchive buttons should honor MP reprompt (#6546) 2026-02-17 18:55:46 +00:00
Patrick Honkonen
436ae9333c [PM-29885] Implement SSO cookie vending authentication flow (#6522)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 18:41:07 +00:00
Patrick Honkonen
9b13cd4498 [PM-30703] Introduce CXF payload parser and update to alpha05 (#6347)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 18:20:52 +00:00
Ignacio
f6cd94485a [PM-32022] Fix browser autofill dialog showing for non-default browsers (#6514) 2026-02-17 16:57:12 +00:00
David Perez
222bc44c99 PM-32354: Filter out archived items from CXP (#6547) 2026-02-17 15:34:38 +00:00
github-actions[bot]
275d90bb61 Update Google privileged browsers list (#6538)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-02-17 14:40:56 +00:00
David Perez
a23183597c PM-32252: Update View Item date information layout (#6544) 2026-02-17 14:30:00 +00:00
David Perez
e3ab4f3d68 Update AGP to v9.0.1 (#6543) 2026-02-17 14:27:58 +00:00
bw-ghapp[bot]
34a7c4455c Update SDK to 2.0.0-5210-4ffddfe5 (#6533)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-17 13:34:52 +00:00
renovate[bot]
4a68c2343d [deps]: Update com.google.devtools.ksp to v2.3.5 (#6541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 16:36:43 +00:00
André Bispo
fb9d16730e [PM-30870] Fix editing blocked autofill URIs (#6532) 2026-02-16 15:51:10 +00:00
renovate[bot]
5c348ac360 [deps]: Lock file maintenance (#6542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 15:47:20 +00:00
bw-ghapp[bot]
3985817c16 Crowdin Pull (#6539)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-16 15:45:56 +00:00
Patrick Honkonen
8664ce4614 [PM-32251] Decouple SDK token repository from network module (#6537)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-13 22:39:31 +00:00
David Perez
f3c746fd49 Update Anroidx dependencies (#6536) 2026-02-13 22:27:54 +00:00
aj-rosado
ce3f0acf74 [PM-31614] feat: Added new UI for the Email verification on sends (#6488) 2026-02-13 22:19:09 +00:00
bw-ghapp[bot]
b20622e7d6 Update SDK to 2.0.0-5131-c0c3ee5f (#6531)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-13 12:20:38 +00:00
David Perez
e939b20a82 PM-31664: Add new SnackbarRelay type specific for the View Screen (#6528) 2026-02-12 21:10:27 +00:00
David Perez
a8e77a5abc PM-32146: Add back 'parent' param to webAuthn url (#6527) 2026-02-12 18:57:41 +00:00
aj-rosado
afa9c28341 [PM-31615] feat: Updated Send network models to support email verification (#6519) 2026-02-12 16:43:05 +00:00
bw-ghapp[bot]
bb44586d76 Update SDK to 2.0.0-5087-3e8a45eb (#6521)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-12 11:40:57 +00:00
Patrick Honkonen
4cdd0b8422 [PM-32029] Implement SDK interfaces for cookie management (#6517)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
2026-02-11 21:02:46 +00:00
David Perez
5a4973d678 PM-31925: Replace 'android' reference with logic in LibraryExtension (#6520) 2026-02-11 17:17:23 +00:00
Patrick Honkonen
a914d12e6f [PM-80371] Enhance CLAUDE.md using bitwarden-init plugin (#6368)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-11 17:03:11 +00:00
David Perez
e5875cd8fe PM-31922: Remove deprecated Android block where possible (#6512) 2026-02-11 15:55:33 +00:00
bw-ghapp[bot]
a3aefd369a Update SDK to 2.0.0-5064-8700dc73 (#6513)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-02-11 15:25:41 +00:00
Mick Letofsky
60a1265c5d Slim down and align with our current practices (#6518) 2026-02-11 13:07:02 +00:00
David Perez
95272d9692 Update Kover to v0.9.7 (#6516) 2026-02-10 23:31:48 +00:00
Patrick Honkonen
3be5bead89 [PM-32011] Add cookie callback flow to AuthRepository (#6510)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 22:33:32 +00:00
David Perez
31d480d6b4 PM-31953: Support multiple schemes for Duo, WebAuthn, and SSO callbacks (#6498) 2026-02-10 20:21:40 +00:00
bw-ghapp[bot]
43940102ff Update SDK to 2.0.0-5046-d59280a3 (#6511)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-10 18:41:45 +00:00
Patrick Honkonen
253f0d7ec4 [PM-31993] Add cookie vendor deep link intent filter (#6507)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 18:26:06 +00:00
David Perez
d7428a15bc PM-31924: Remove the 'android.dependency.useConstraints' gradle property (#6509) 2026-02-10 18:24:30 +00:00
Patrick Honkonen
5d84df9d31 [PM-31993] Add deep link utilities for cookie vendor callbacks (#6506)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 16:17:54 +00:00
Patrick Honkonen
d8c69a3243 [PM-31982] Add CookieDiskSource for cookie persistence (#6504)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
2026-02-10 15:39:16 +00:00
Eran Boudjnah
f0837f7668 [PM-22523] PM-19476: Allow empty string as word separator (#5334) 2026-02-10 14:00:46 +00:00
Marc Nguyen
f094430d6c [PM-31980] Fix passkeys on some browsers by fixing JSON parsing (#6502) 2026-02-10 13:44:14 +00:00
Patrick Honkonen
cf3660a5aa [PM-31954] Add server communication models to ConfigResponseJson (#6500) 2026-02-10 13:17:34 +00:00
bw-ghapp[bot]
5300386ce3 Update SDK to 2.0.0-5021-f954d14b (#6495)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-10 12:20:00 +00:00
David Perez
eb24a50baa Update to Kotlin v2.3.10 (#6499) 2026-02-10 09:12:26 +00:00
David Perez
4d31dccc74 Update the gradlew Wrapper to v9.3.1 (#6496) 2026-02-09 22:20:54 +00:00
David Perez
8ee721c8ae PM-31927: Pre-emptively patch Brave browser Autofill bug (#6497) 2026-02-09 21:32:19 +00:00
David Perez
c0907b867b PM-31926: Add Autofill reminder for Vivaldi browser (#6494) 2026-02-09 21:26:04 +00:00
David Perez
6eba9ecd4b Update Firebase BOM to v34.9.0 (#6493) 2026-02-09 21:25:43 +00:00
David Perez
594cb507df Update the ZonedDateTimeSerializer to be more lenient when deserializing (#6489) 2026-02-09 14:58:09 +00:00
bw-ghapp[bot]
e615bdbea5 Update SDK to 2.0.0-5002-7f4059e7 (#6481)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-09 14:15:16 +00:00
bw-ghapp[bot]
071d3c8cd5 Crowdin Pull (#6491)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-09 14:08:41 +00:00
David Perez
ad3a9a6c2e Update AGP to v9.0.0 (#6479) 2026-02-06 20:55:11 +00:00
David Perez
cbe13d2015 PM-31735: Add the archivedDate property to the updateCipher API (#6483) 2026-02-05 20:23:18 +00:00
Patrick Honkonen
f728c15794 Configure Claude to use the Bitwarden marketplace (#6484) 2026-02-05 18:16:08 +00:00
David Perez
586f24ffec PM-31734: Add archived item filtering for passkeys (#6482) 2026-02-05 17:05:42 +00:00
Patrick Honkonen
8e8367a82f [PM-31775] Refactor popUpToCompleteRegistration to use type-safe KClass reference (#6480) 2026-02-05 16:44:01 +00:00
David Perez
47b9509062 Update build optimizations (#6433) 2026-02-04 20:08:15 +00:00
David Perez
29648e03c8 Update protobuf to v4.33.5 (#6478) 2026-02-04 17:36:20 +00:00
David Perez
15e217bc49 PM-31656, PM-31658, PM-31659: Address Archive feature bugs (#6473) 2026-02-04 17:33:29 +00:00
bw-ghapp[bot]
7ec4faf424 Update SDK to 2.0.0-4872-065ef30b (#6464)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-04 17:30:35 +00:00
Patrick Honkonen
e31fa46a73 [PM-30279] Extract credential provider handling to dedicated activity (#6472)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:05:28 +00:00
David Perez
aff8b0347b Update test tools (#6468) 2026-02-03 21:54:42 +00:00
renovate[bot]
f4d34e4649 [deps]: Update androidx.credentials:credentials to v1.6.0-rc01 (#6455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 21:35:42 +00:00
David Perez
2e18b079f8 Update Androidx dependencies (#6467) 2026-02-03 18:29:31 +00:00
aj-rosado
b0eea88af2 [PM-31613] Add send email verification feature flag (#6470) 2026-02-03 17:09:15 +00:00
Patrick Honkonen
4cac4d6a6e Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 14:33:58 +00:00
David Perez
a2ec99fb05 Remove the configuration cache to avoid play store build issues (#6466) 2026-02-02 19:10:20 +00:00
Patrick Honkonen
d49629de9e Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00
renovate[bot]
c85cbb70a1 [deps]: Lock file maintenance (#6460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 18:33:13 +00:00
github-actions[bot]
e482820201 Update Google privileged browsers list (#6452)
Co-authored-by: GitHub Actions Bot <actions@github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2026-02-02 17:59:57 +00:00
Shamim Shahrier Emon
74d45c3906 [PM-31393] Sends: UI/UX inconsistency of the password field (#6435) 2026-02-02 17:58:19 +00:00
Lucas
12eb42097c [PM-30259] Add iodéOS browser to community FIDO2 privileged list (#6298) 2026-02-02 17:34:29 +00:00
David Perez
0811d14606 PM-31603: Add toast when resetpassword succeeds (#6465) 2026-02-02 17:26:01 +00:00
Ruyut
365067e5be [PM-31583] Fix typos in authentication-related KDoc comments (#6461) 2026-02-02 15:29:31 +00:00
bw-ghapp[bot]
9652c7e049 Crowdin Pull (#6453)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-02 14:14:55 +00:00
bw-ghapp[bot]
6cc519bc3f Update SDK to 2.0.0-4835-5285d3fc (#6446)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-02 14:07:02 +00:00
aj-rosado
9f82b42e36 [BWA-182] Add mTLS support for Glide image loading (#6125)
Co-authored-by: David Perez <david@livefront.com>
2026-01-30 19:57:59 +00:00
Patrick Honkonen
5531b478d3 Add comprehensive tests for FileManagerImpl (#6425)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 19:18:53 +00:00
Ruyut
fe5b61bf25 [PM-31445] Fix minor KDoc typos and wording issues. (#6441) 2026-01-30 19:15:03 +00:00
Patrick Honkonen
92ba38c831 [PM-31446] fix:Append assetlinks.json path to DAL URLs (#6447) 2026-01-30 18:22:00 +00:00
Patrick Honkonen
675b346666 Add comprehensive tests for ExportViewModel (#6442)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:16:24 +00:00
Patrick Honkonen
0f087b7d15 Add comprehensive tests for AuthenticatorRepositoryImpl (#6424)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 15:41:21 +00:00
Álison Fernandes
99a6dd7647 [PM-31436] Consolidate Feature categories in release notes and add labels (#6439) 2026-01-30 14:01:08 +00:00
bw-ghapp[bot]
ea4df7dde9 Update SDK to 2.0.0-4818-c1e4bb66 (#6444)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-30 12:08:39 +00:00
Álison Fernandes
f541919d39 [PM-31292] ci: update renovate config to remove gradle group and ignore sdk updates (#6437) 2026-01-29 21:06:49 +00:00
Amy Galles
3d1f46983a use option to determine if release will be marked latest (#6417) 2026-01-29 18:41:36 +00:00
David Perez
b0084d2f1f Set cache problem to warning (#6436) 2026-01-29 16:35:10 +00:00
David Perez
0d0a5cb292 Item migration flow has been moved into a graph (#6427) 2026-01-29 15:16:02 +00:00
bw-ghapp[bot]
ebfe293c81 Update SDK to 2.0.0-4800-bed92cae (#6431)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-29 13:49:01 +00:00
Patrick Honkonen
254b2cd25b Add comprehensive tests for Import Parsers and UuidManager (#6423)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 21:55:06 +00:00
Patrick Honkonen
3d974d710c [PM-31370] Refactor stringToUri and consolidate FileManager (#6432)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:37:24 +00:00
bw-ghapp[bot]
7717a09c06 Update SDK to 2.0.0-4772-490c1be4 (#6395)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-28 18:55:02 +00:00
David Perez
674cff1c3c PM-31363: Fix crash caused by a duplicate ID (#6428) 2026-01-28 18:45:57 +00:00
Álison Fernandes
ca9ec45548 [PM-31343] Fix dependencies listed under Maintenance by adding a new fallback section to release.yml (#6420) 2026-01-28 14:59:34 +00:00
David Perez
009136ce1e Minor cleanup of the MigrateToMyItemsScreen (#6421) 2026-01-28 14:59:22 +00:00
David Perez
19a3697605 Remove intialization of NetworkConnectionManager from application class (#6419) 2026-01-28 14:57:10 +00:00
David Perez
954571ff4a Optimize build times (#6418) 2026-01-27 19:01:20 +00:00
David Perez
66316e4bd2 Cleanup organizations (#6391) 2026-01-27 17:28:09 +00:00
David Perez
9463cf646b Update Kotlin and associated dependencies (#6408) 2026-01-27 17:14:39 +00:00
David Perez
e81710c24f GradlewWrapper updates (#6415) 2026-01-27 17:14:24 +00:00
David Perez
71466405fa Update testing tools (#6407) 2026-01-27 15:21:52 +00:00
David Perez
618bdc7424 Update protobufs to v4.33.4 (#6414) 2026-01-27 15:21:30 +00:00
David Perez
0f05e30997 Update the Compose BOM to v2026.01.00 (#6401) 2026-01-27 15:21:13 +00:00
David Perez
006a13d5ac Update Sonarqube to v7.2.2.6593 (#6406) 2026-01-26 21:48:01 +00:00
David Perez
1d35004999 Update the Gradle Wrapper to the latest version (#6405) 2026-01-26 17:42:53 +00:00
David Perez
85249987aa Update app version name to 2026.2.0 (#6409) 2026-01-26 17:42:27 +00:00
bw-ghapp[bot]
f05cf773fb Crowdin Pull (#6412)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-26 14:54:34 +00:00
Patrick Honkonen
2e311b6c4a [PM-30899] Store account keys upon SSO user creation (#6384) 2026-01-23 19:51:25 +00:00
David Perez
ee5ed77bc1 Update to Junit v6.0.2 (#6402) 2026-01-23 18:43:33 +00:00
aj-rosado
04a3cd227e [PM-30644] Removing special circumstance validation from MigrateToMyItems route (#6358) 2026-01-23 17:06:31 +00:00
aj-rosado
ec28dde6d2 [PM-31081] Added snackbar when items are successfully migrated (#6394) 2026-01-23 16:51:41 +00:00
David Perez
319872ccf9 PM-29693: Add introducing archive action card to vault screen (#6390) 2026-01-23 16:50:43 +00:00
aj-rosado
9f1fad8be0 [PM-28990] Skipping vault migration on Network or Timeout error (#6393) 2026-01-23 16:06:17 +00:00
aj-rosado
0395d489c2 [PM-31069] Add OrganizationId support for Vault Migration operations (#6397) 2026-01-23 16:05:55 +00:00
David Perez
2acf429f67 PM-29696: Add action card for lapsed premium subscription (#6389) 2026-01-23 15:24:00 +00:00
David Perez
721fbbb82c PM-31162: Update copy on the snackbar for archive feature (#6399) 2026-01-23 15:07:27 +00:00
David Perez
6d198bd8c9 Update to Firebase v34.8.0 (#6396) 2026-01-23 15:07:09 +00:00
Álison Fernandes
8658f1d42c [PM-14880] ci: Address automated PR labeling workflow feedback (#6400) 2026-01-22 21:25:09 +00:00
Shamim Shahrier Emon
acc3e24d65 [PM-30664] Unlock with PIN doesn’t appear as enabled after enabling ‘Require master password on app restart’ (#6344) 2026-01-21 18:42:02 +00:00
bw-ghapp[bot]
40c8346bf7 Update SDK to 2.0.0-4676-0544ddec (#6388)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-21 17:24:18 +00:00
aj-rosado
a7badf8b0b [PM-28470] Implement revoke from organization (#6383)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 16:53:26 +00:00
David Perez
c52910e74a PM-31043: Add unarchive button to overflow menus (#6387) 2026-01-21 16:50:30 +00:00
David Perez
afc1ff4d7a PM-31042: Add overflow archive button (#6385) 2026-01-21 14:50:00 +00:00
bw-ghapp[bot]
8cb4fab1de Update SDK to 2.0.0-4672-b3e4ea24 (#6371)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-21 11:24:07 +00:00
David Perez
f79113aa7f Fix minor typos (#6386) 2026-01-20 21:49:38 +00:00
renovate[bot]
7d814df04e [deps]: Lock file maintenance (#6382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:45:36 +00:00
David Perez
49b208f013 PM-29697: Finish View and Edit Cipher UI for archive (#6377) 2026-01-20 18:43:14 +00:00
bw-ghapp[bot]
8d33e6660a Crowdin Pull (#6380)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-20 15:48:58 +00:00
David Perez
27a0f5172c Move Vault Listing Dialog clicks to VaultItemListingHandlers (#6375) 2026-01-16 19:50:26 +00:00
David Perez
3e470ebc25 PM-30868: Archive Banner on Edit Item Screen (#6367) 2026-01-16 19:48:15 +00:00
aj-rosado
eb18ca04a0 [PM-28471] Migrate individual vault to organization (#6352) 2026-01-16 19:11:43 +00:00
David Perez
759e0563a9 PM-30897: Add archive and unarchive button on Edit Cipher Screen (#6372) 2026-01-16 17:17:19 +00:00
David Perez
757f444493 PM-29694: Update archive empty state (#6369) 2026-01-15 18:29:16 +00:00
David Perez
98ba1690bf PM-30807: Add archived header to ViewItem Screen (#6362) 2026-01-15 15:45:01 +00:00
David Perez
44274a888e PM-30795: Update cipher filtering logic for archive (#6359) 2026-01-15 15:14:26 +00:00
bw-ghapp[bot]
77cc0d5fba Update SDK to 2.0.0-4524-513f18bf (#6361)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-15 15:13:40 +00:00
Álison Fernandes
026393384b [PM-30823] ci: Fix BWA Play Store publishing for rc cherry picks and update upload step names (#6360) 2026-01-15 14:42:45 +00:00
Patrick Honkonen
7daeaca63e refactor(claude): Refine reviewing-changes skill description for clarity and usage (#6366) 2026-01-15 13:59:58 +00:00
Gavin Gui
353e7e9a4e [PM-30394] PM-29960: Skip biometric prompt on Xiaomi HyperOS (#6316) 2026-01-14 16:08:57 +00:00
bw-ghapp[bot]
2d824f96f5 Update SDK to 2.0.0-4505-df9bd639 (#6355)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-14 16:00:14 +00:00
David Perez
a9b1623f8b PM-30774: Add archiving and unarchiving network requests (#6356) 2026-01-14 14:49:01 +00:00
David Perez
6d72d3a1c9 PM-30767: Add archive row to Vault Screen (#6354) 2026-01-13 21:41:45 +00:00
David Perez
f6edc19595 Remove the unused showDivider flag from BitwardenGroupItem (#6353) 2026-01-13 16:42:51 +00:00
David Perez
45125a94c2 Update archive string with noun suffix (#6351) 2026-01-13 16:38:05 +00:00
David Perez
66900f71df End subtext and end icon support to BitwardenGroupItem (#6349) 2026-01-13 15:27:32 +00:00
bw-ghapp[bot]
d12c546c9a Update SDK to 2.0.0-4498-7681828f (#6350)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-13 13:08:36 +00:00
David Perez
be365eec1c PM-30708: Add archive item navigation (#6348) 2026-01-12 21:30:04 +00:00
Álison Fernandes
d86959b375 [PM-14880] ci: Update feature labels (#6346) 2026-01-12 18:45:23 +00:00
bw-ghapp[bot]
282cce8ce0 Update SDK to 2.0.0-4479-ad9fb51d (#6345)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-12 17:00:34 +00:00
bw-ghapp[bot]
e8eaf4e68c Crowdin Pull (#6342)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-12 15:06:13 +00:00
Patrick Honkonen
41dfc2b6e8 Improve KDoc on StateFlowExtensions (#6338) 2026-01-09 16:38:43 +00:00
Patrick Honkonen
7bfd4b5a6c Document best practices for Clock/Time handling (#6340) 2026-01-09 14:53:38 +00:00
bw-ghapp[bot]
557b667dab Update SDK to 2.0.0-4441-c5a3b833 (#6333)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-09 14:37:39 +00:00
aj-rosado
eff4ce7abb [PM-28468] Updated validation and navigation for MigrateToMyItems (#6279)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-08 21:29:28 +00:00
David Perez
577e3c04e3 Add the Archive items feature flag (#6337) 2026-01-08 20:55:04 +00:00
David Perez
203313eb1d Improve clock usage patterns (#6336) 2026-01-08 20:54:33 +00:00
David Perez
5d308aa95f PM-30522: Add support for processing app links for Duo, WebAuthn, and SSO (#6332) 2026-01-07 19:45:04 +00:00
David Perez
c4a94cf5d1 Add concrete FlightRecorderDiskSource (#6281) 2026-01-07 19:30:53 +00:00
Lucas
5245a7a0c7 [PM-30258] Remove CalyxOS Chromium from the FIDO2 privileged list (#6297) 2026-01-07 17:13:59 +00:00
bw-ghapp[bot]
9432df6ff4 Update SDK to 2.0.0-4408-ef987b96 (#6331)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-07 16:33:15 +00:00
David Perez
461e1e1ff9 Update generated SSO uri to be typed (#6329) 2026-01-06 21:02:36 +00:00
David Perez
a8ef32ae76 Allow trailing commas in JSON (#6326) 2026-01-06 15:40:37 +00:00
Patrick Honkonen
769bfc83af [VULN-362] Move Compose tooling dependency to debugImplementation (#6327) 2026-01-06 15:25:20 +00:00
Patrick Honkonen
29d84d69f5 [PM-28271] Rename validatePin to validatePinUserKey and update SDK usage (#6323) 2026-01-05 22:08:21 +00:00
David Perez
05d003edb2 Update Firebase BOM to latest versions (#6324) 2026-01-05 21:22:59 +00:00
bw-ghapp[bot]
03562a8605 Update SDK to 2.0.0-4373-3c666766 (#6311)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-05 18:27:32 +00:00
Lucas
e6c46169fb [PM-30260] Add WebLibre to the FIDO2 privileged community list (#6299) 2026-01-05 18:24:21 +00:00
David Perez
7d4d7a25b5 Update Androidx dependencies (#6322) 2026-01-05 17:06:28 +00:00
renovate[bot]
1cb37b8458 [deps]: Lock file maintenance (#6320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:05:52 +00:00
David Perez
3c7b70f325 PM-30389: Allow for different auth tab schemes (#6315) 2026-01-05 16:36:17 +00:00
David Perez
9a8c504c8b Move TestHelpers to core test-fixtures module (#6314) 2026-01-05 15:57:13 +00:00
bw-ghapp[bot]
b07a92f7d6 Crowdin Pull (#6317)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-05 15:29:42 +00:00
Mick Letofsky
674cde9869 Revert review Code Triggered by labeled event (#6310) 2025-12-31 16:20:51 +00:00
Patrick Honkonen
28c9637655 [deps]: Update Google ProtoBuf dependencies (#6308) 2025-12-30 19:01:39 +00:00
bw-ghapp[bot]
2d228b8496 Update SDK to 2.0.0-4254-6c954013 (#6218)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-12-30 18:12:39 +00:00
bw-ghapp[bot]
3bc538c1f8 Crowdin Pull (#6286)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-30 17:58:21 +00:00
Mick Letofsky
99717ab5d5 Review Code Triggered by labeled event (#6307) 2025-12-30 16:56:06 +00:00
Álison Fernandes
d98e459129 [PM-14880] Add pull-request trigger to PR Labeling workflow and address test findings (#6305) 2025-12-30 14:39:00 +00:00
Álison Fernandes
ebed1bd3cd [PM-14880] Label updates to fido2 privileged apps lists (#6304) 2025-12-29 21:00:16 +00:00
Álison Fernandes
f4e23e85d2 [PM-14880] ci: Update labels of automated PRs; set labels for PRs created by the crowdin-pull.yml workflow (#6303) 2025-12-29 20:17:14 +00:00
Álison Fernandes
474acc05a6 [PM-14880] ci: Adds categories for automated release notes (#6302) 2025-12-29 20:16:01 +00:00
aj-rosado
87faba6824 Updated sdk to a version that fixes the password protected export issues (1.0.0-4328-km-fix-cherry-pick) (#6300) 2025-12-29 15:29:22 +00:00
renovate[bot]
89fb9c92d3 [deps]: Lock file maintenance (#6292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:45 +00:00
renovate[bot]
77a58f344d [deps]: Update actions/upload-artifact action to v6 (#6290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:08 +00:00
renovate[bot]
dda32075d0 [deps]: Update actions/checkout action to v6 (#6289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:16:08 +00:00
renovate[bot]
038931312d [deps]: Update actions/cache action to v5 (#6288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:15:11 +00:00
Patrick Honkonen
7cd0e2c176 PM-29843: Record item org migration events (#6275) 2025-12-29 14:18:10 +00:00
Álison Fernandes
0975144342 [PM-29913] ci: Fix release notes fetch failure while creating GitHub Releases (#6282) 2025-12-19 20:59:01 +00:00
Patrick Honkonen
07415844ee [PM-29947] Remove ResetMasterPassword property from token response model (#6285) 2025-12-19 15:34:48 +00:00
David Perez
913d877737 Remove flaky tests (#6278) 2025-12-18 21:47:12 +00:00
Katherine Bertelsen
c16da5090e [PM-29911] Update cron jobs to run at midnight on Sundays (#6280) 2025-12-18 14:50:32 +00:00
David Perez
b79aca7338 Move extensions to common module (#6276) 2025-12-17 16:19:20 +00:00
David Perez
7834d5bf27 PM-29827: Move FlightRecorderManager to common data module (#6274) 2025-12-16 17:37:51 +00:00
Patrick Honkonen
7c929c3713 [PM-29842] Add organization event types for item migration acceptance and rejection (#6273) 2025-12-16 15:38:16 +00:00
Patrick Honkonen
7f032a8732 PM-29824: Add bulk share ciphers network layer implementation (#6271) 2025-12-16 14:12:33 +00:00
David Perez
ef6714fa17 PM-29806: Move FlightRecorderWriter to the data module (#6270) 2025-12-15 21:43:17 +00:00
Patrick Honkonen
d09945d80b [PM-29297] Add MigrateToMyItemsScreen (#6239)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 20:49:43 +00:00
David Perez
30ce512091 PM-29442: Change 2fa field to not be a password field (#6269) 2025-12-15 18:58:44 +00:00
David Perez
bdbcd5bdc2 PM-29795: Move FileManager to data module (#6268) 2025-12-15 18:19:32 +00:00
David Perez
b4414073c7 Update Mockk and Kover (#6260) 2025-12-12 16:40:34 +00:00
David Perez
1594de39c1 Update Androidx Camera to v1.5.2 (#6259) 2025-12-12 16:39:00 +00:00
David Perez
f0c5c8f421 Update to AGP v8.13.2 (#6258) 2025-12-12 16:38:15 +00:00
bw-ghapp[bot]
2a343555bf Crowdin Pull (#6261)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-12 15:38:39 +00:00
David Perez
dff6a13cd7 Update OkHttp to v5.3.2 (#6257) 2025-12-11 19:33:29 +00:00
Patrick Honkonen
e415145c53 PM-29491: Implement LeaveOrganizationScreen (#6253)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 16:35:15 +00:00
Patrick Honkonen
54ea921b25 Update STYLE_AND_BEST_PRACTICES.md to clarify KDoc requirements and fix whitespace (#6256) 2025-12-11 16:18:23 +00:00
gitclonebrian
e87ffa3902 [BRE-1333] Added permissions to token generation step to limit token scope (#6171) 2025-12-10 22:36:10 +00:00
David Perez
00cded3a02 PM-1908: Push notifications for non-active accounts prompt for future sync (#6252) 2025-12-10 15:27:09 +00:00
David Perez
1503e3f769 PM-29172: Update Authenticator biometric encryption (#6240) 2025-12-10 14:54:44 +00:00
aj-rosado
6840a6c207 [PM-28836] Add AndroidManifest permission for HEADSET_CAMERA (#6251) 2025-12-10 11:09:08 +00:00
Patrick Honkonen
d32e767c62 [PM-28504] Add testharness build workflow with dynamic versioning (#6181)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 21:32:11 +00:00
aj-rosado
4a874668f2 [PM-28468] Added service methods to migration to MyItems validation (#6248) 2025-12-09 15:58:23 +00:00
David Perez
cd27fe339d Move BiometricsEncryptionManager into the AuthRepository (#6249) 2025-12-09 15:32:25 +00:00
David Perez
2eb8ad4221 PM-28355: Clear pin data on hard-logout or security stamp (#6232) 2025-12-08 16:51:18 +00:00
renovate[bot]
28db795790 [deps]: Update actions/checkout action to v6 (#6247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 14:34:31 +00:00
David Perez
8c6782dcb1 Move MissingPropertyException to common location (#6237) 2025-12-05 19:08:39 +00:00
David Perez
127809b8df Address several small lint warning throughout the app (#6233) 2025-12-05 17:47:52 +00:00
aj-rosado
ca13e615ec [PM-28442] Added feature flag for migrate myvault to myitems (#6235) 2025-12-05 16:50:30 +00:00
bw-ghapp[bot]
5e3e8a04aa Crowdin Pull (#6234)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-05 14:53:29 +00:00
Patrick Honkonen
8077895eb8 Update ZXing library version (#6230) 2025-12-04 19:52:24 +00:00
Patrick Honkonen
33e9313c6c Update SonarQube plugin version (#6231) 2025-12-04 19:19:54 +00:00
Patrick Honkonen
593bfbf8cf [PM-28352] Add logging to Credential Manager and Origin Manager flows (#6229) 2025-12-04 18:22:45 +00:00
Patrick Honkonen
4905358adb [PM-28467] Add revisionDate to policy JSON model (#6228) 2025-12-04 18:22:23 +00:00
renovate[bot]
02733f785b [deps]: Lock file maintenance (#6197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 18:01:23 +00:00
Patrick Honkonen
8baa4bf041 [PM-29096] Update Fastlane and Gemfile dependencies (#6216) 2025-12-04 16:20:21 +00:00
David Perez
4d20453d0f PM-25632: Ensure that we use lowercase email addresses when creating a fingerprint (#6227) 2025-12-04 15:34:17 +00:00
David Perez
4b951a1df2 PM-28634: Update Autofill terms to support other languages better (#6226) 2025-12-04 14:55:23 +00:00
André Bispo
9349b235bc [PM-27290] Remove password unlock method (#6176) 2025-12-04 10:53:40 +00:00
Patrick Honkonen
e9ab5f2def [PM-29097] Fix privacy statement alignment in landscape mode (#6225) 2025-12-03 22:10:58 +00:00
David Perez
3bef282426 Update Androidx dependencies to the latest versions (#6224) 2025-12-03 21:25:50 +00:00
Patrick Honkonen
e1bb3a4b5d [PM-27118] Restrict Credential Exchange import based on Personal Ownership policy (#6220) 2025-12-03 20:15:53 +00:00
David Perez
1904c4ffb9 PM-28522: Update the LoginWithDevice ui (#6221) 2025-12-03 19:41:34 +00:00
aj-rosado
26e7178300 [PM-28835] Added validations to prevent duplicate press on buttons (#6209) 2025-12-03 17:46:03 +00:00
David Perez
2c01abda46 [deps]: Update ksp (#6217) 2025-12-02 18:20:51 +00:00
bw-ghapp[bot]
b86cbfcd87 Update SDK to 1.0.0-3958-7f09fd2f (#6213)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-12-02 14:57:18 +00:00
aj-rosado
3f303d3f39 [BWA-179] Added clarification of functionality on Authenticator's ExportScreen (#6190) 2025-12-02 10:01:00 +00:00
David Perez
ca7a65fc95 PM-28522: Update the Login With Device Screen (#6184) 2025-12-01 16:25:30 +00:00
bw-ghapp[bot]
f02b374e98 Update SDK to 1.0.0-3928-2cca3d46 (#6205)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-12-01 14:26:56 +00:00
bw-ghapp[bot]
1a90860080 Crowdin Pull (#6206)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-01 14:16:24 +00:00
Patrick Honkonen
adf83cd315 [PM-28157] Revert "Add string extension to prefix URIs with www" (#6192)
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2025-12-01 14:12:14 +00:00
Patrick Honkonen
489c0ea8d6 Enhance code review skill documentation with TOCs and missing severity categories (#6186)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 19:31:25 +00:00
bw-ghapp[bot]
9831358a8b Update SDK to 1.0.0-3908-4b0d1280 (#6201)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-11-26 23:55:56 +00:00
bw-ghapp[bot]
8bdbccd8de Update SDK to 1.0.0-3896-f75a58cd (#6198)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-11-25 00:57:36 +00:00
bw-ghapp[bot]
a75d904317 Update SDK to 1.0.0-3674-c60a5d79 (#6064)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-11-21 22:07:08 +00:00
Patrick Honkonen
a395f28eba [PM-28086] Add testharness for Credential Manager and Autofill testing (#6159)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-21 19:56:24 +00:00
bw-ghapp[bot]
53e358d7b3 Crowdin Pull (#6189)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-21 13:16:39 +00:00
David Perez
663eb3641f PM-28545: Remove the compatibility mode toggle from the Autofill screen (#6188) 2025-11-21 13:15:40 +00:00
David Perez
ab305b2631 PM-28525: Update the LoginApprovalScreen ui (#6187) 2025-11-20 22:34:36 +00:00
aj-rosado
946b0784e0 [PM-27816] Not clearing the fingerprint on requests that don't return fingerprint on LoginWithDevice (#6185) 2025-11-20 19:57:04 +00:00
Patrick Honkonen
167a46a073 [PM-21391] Remove debug credential provider configuration (#6182)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 19:15:35 +00:00
Patrick Honkonen
7b491d3c3c [PM-28157] Add string extension to prefix URIs with www (#6183)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 15:32:54 +00:00
David Perez
7918abdccf PM-28492: Replace Authenticator Toasts with Snackbars (#6180) 2025-11-19 22:04:28 +00:00
Nailik
5ec0a1986d [PM-24148] add credential manager provider for create passwords (#5579)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-11-19 22:00:53 +00:00
renovate[bot]
839e9e8a1a [deps]: Update actions/checkout action to v5 (#6144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 17:38:13 +00:00
David Perez
979237b751 PM-28408: Update CameraPreview composable to address flakey test (#6178) 2025-11-19 15:43:02 +00:00
Dev Sharma
621f97d161 [PM-27869] fix/[PM-26241] : draw out keyboard on talkback click (#6129) 2025-11-18 18:51:15 +00:00
André Bispo
d81b0005ee [PM-27150] React to device changes on device screen unlock method (#6103) 2025-11-18 16:02:35 +00:00
David Perez
794b27a750 Update logic for handling the pin protected user key (#6169) 2025-11-18 15:32:39 +00:00
David Perez
169b21cfdb PM-28053: Ensure any exception thrown during re-auth is an IO exception (#6175) 2025-11-17 20:24:48 +00:00
Álison Fernandes
4623a4f079 [PM-14880] ci: Add automated PR labelling based on file paths and title patterns (#6157) 2025-11-17 20:19:12 +00:00
bw-ghapp[bot]
21afa81322 Crowdin Pull (#6167)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-14 15:34:06 +00:00
David Perez
55c7ab4cee Update RTL transitions to go the correct direction (#6166) 2025-11-13 16:19:07 +00:00
Mick Letofsky
7a40bfe522 [PM-27181] - Grant additional permissions for review code (#6165) 2025-11-13 13:55:31 +00:00
Álison Fernandes
0c234ee0aa [PM-28029] ci: add missing permission to fdroid job to fix f-droid build failures (#6163) 2025-11-13 00:28:25 +00:00
David Perez
7b0b93a204 Update Autofill to detect url bar webDomain for certain browsers (#6141) 2025-11-12 22:04:03 +00:00
David Perez
473416c1b4 Update App version name to '2025.11.1' (#6162) 2025-11-12 22:03:36 +00:00
renovate[bot]
f3646790e3 [deps]: Update actions/upload-artifact action to v5 (#6145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 17:26:16 +00:00
David Perez
35b87f4390 Replace blank account name with null (#6158) 2025-11-12 15:11:00 +00:00
David Perez
7120eefc94 PM-28056: Consolidate IntentManager extensions (#6156) 2025-11-11 19:48:58 +00:00
aj-rosado
5eb56cafaa [PM-27119] Prevent import card data when ITEM_RESTRICT_TYPES policy is active (#6123) 2025-11-11 19:24:38 +00:00
renovate[bot]
b2d94fae40 [deps]: Update gradle minor (#6143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 15:21:39 +00:00
Álison Fernandes
ad748eef7f [PM-28029] Address Sonar Cloud and Linter errors (#6151) 2025-11-11 14:16:55 +00:00
David Perez
8010e8d6c3 All tests should assert if a value is displayed (#6153) 2025-11-11 14:16:22 +00:00
Álison Fernandes
7a6a493f24 [PM-28041] Remove SDK Update PR changelog list size limit (#6152) 2025-11-10 21:58:01 +00:00
Álison Fernandes
4032d2bb5c [PM-27901] Add f-droid fastlane metadata (#6134)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2025-11-10 20:53:40 +00:00
Patrick Honkonen
e2c9aae9c1 Bump JUnit from 6.0.0 to 6.0.1 (#6149) 2025-11-10 16:48:33 +00:00
Patrick Honkonen
56566b958c Update OkHttp to version 5.3.0 (#6148) 2025-11-10 15:42:51 +00:00
Patrick Honkonen
06bf603ec8 Update Firebase BoM to 34.5.0 (#6150) 2025-11-10 15:06:51 +00:00
Patrick Honkonen
79d6da0a61 Exclude Bitwarden Android SDK from Renovate auto-updates (#6147) 2025-11-10 14:23:58 +00:00
renovate[bot]
2d2ea9acee [deps]: Lock file maintenance (#6146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 13:49:13 +00:00
David Perez
05d6fe1ba1 Fix a test that was not running (#6140) 2025-11-07 21:34:18 +00:00
David Perez
1024f77ddf Clarify package id parsing and AutofillView creation (#6138) 2025-11-07 18:07:20 +00:00
bw-ghapp[bot]
50e50eb08c Crowdin Pull (#6135)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-07 15:28:24 +00:00
Patrick Honkonen
bd98df6eb9 PM-27902: Logout user after successful master password reset (#6133)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 14:33:07 +00:00
Patrick Honkonen
9baec6e6a5 Enforce strict brevity in reviewing-changes skill output (#6131)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 07:18:07 +00:00
David Perez
efd15b027c PM-27845: Move SnackbarRelayManager to ui module (#6127) 2025-11-06 18:50:06 +00:00
aj-rosado
b715a51188 [PM-27806] Reverted changes to order of StorePolicies after sync (#6130) 2025-11-06 16:54:22 +00:00
Patrick Honkonen
94ed32790f [PM-27752] Add certificate signature verification to AuthenticatorBridge (#6126)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 13:15:26 +00:00
Patrick Honkonen
dca97e0c8e Refine reviewing-changes skill to eliminate verbosity and praise (#6128)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 21:15:40 +00:00
David Perez
aceb96d18f PM-27846: Move DispatcherManager to core module (#6124) 2025-11-05 18:26:44 +00:00
Álison Fernandes
510072b34f [PM-27834] Use Authenticator Bridge as a project reference (#5793) 2025-11-05 14:46:35 +00:00
Patrick Honkonen
7324be04f4 Reduce verbosity in reviewing-changes skill for clean PRs (#6121)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 07:05:21 +00:00
David Perez
bfbe47f48f PM-27597: Update Yubikey illustration to match the rest of the app (#6087) 2025-11-04 22:15:40 +00:00
David Perez
2bb06063c7 PM-27817: Consolidate totp parsing with TotpUriUtils (#6122) 2025-11-04 21:51:01 +00:00
David Perez
ed47ff4d18 PM-27771: Improve TOTP parsing (#6119) 2025-11-04 20:17:03 +00:00
David Perez
448ba97ae2 PM-24277: Add language selector to Authenticator (#6120) 2025-11-04 19:09:20 +00:00
David Perez
14e833247d PM-27770: Update error parsing when creating or updating a cipher (#6118) 2025-11-04 19:08:10 +00:00
David Perez
4b7fcdb6ea PM-27756: Create common ExitManager (#6117) 2025-11-04 18:08:50 +00:00
André Bispo
0959284e6f [PM-26736] Fix push notification logout reason serialization (#6116) 2025-11-03 20:33:56 +00:00
David Perez
1f24ca7de1 PM-27755: Create a common LocalQrCodeAnalyzer for both apps (#6115) 2025-11-03 20:13:39 +00:00
André Bispo
dc79176274 [PM-27657] KDF silent update with MasterPasswordUnlock data (#6113) 2025-11-03 19:51:26 +00:00
Patrick Honkonen
a631d6822a [PM-24971] Sanitize passkey attestation options from AliExpress (#6106) 2025-11-03 18:15:00 +00:00
David Perez
845a5dec22 PM-27703: Update Authenticator navigation (#6109) 2025-11-03 16:28:23 +00:00
renovate[bot]
b1195b5f46 [deps]: Update org.sonarqube to v7 (#6082)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 15:07:41 +00:00
Patrick Honkonen
8de3a07715 Optimize reviewing-changes skill (#6099)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2025-11-01 07:28:18 +00:00
David Perez
9c4bd2ee14 Remove unused xml colors from Authenticator (#6108) 2025-10-31 19:59:10 +00:00
David Perez
6dad8c4def PM-27705: Enable filterTouchesWhenObscured in Authenticator for security (#6105) 2025-10-31 19:16:13 +00:00
David Perez
bc74337eae Add Push chevron to Block autofill button (#6102) 2025-10-31 17:37:04 +00:00
1361 changed files with 77766 additions and 14486 deletions

View File

@@ -1,105 +1,132 @@
# Claude Guidelines
# Bitwarden Android - Claude Code Configuration
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
## Core Directives
## Overview
**You MUST follow these directives at all times.**
- Multi-module Android application: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
- Zero-knowledge architecture: encryption/decryption happens client-side via Bitwarden SDK
- Target users: End-users via Google Play Store and F-Droid
1. **Adhere to Architecture**: All code modifications MUST follow patterns in `docs/ARCHITECTURE.md`
2. **Follow Code Style**: ALWAYS follow `docs/STYLE_AND_BEST_PRACTICES.md`
3. **Error Handling**: Use Result types and sealed classes per architecture guidelines
4. **Best Practices**: Follow Kotlin idioms (immutability, appropriate data structures, coroutines)
5. **Document Everything**: All public APIs require KDoc documentation
6. **Dependency Management**: Use Hilt DI patterns as established in the project
7. **Use Established Patterns**: Leverage existing components before creating new ones
8. **File References**: Use file:line_number format when referencing code
### Key Concepts
## Code Quality Standards
- **Zero-Knowledge Architecture**: Server never has access to unencrypted vault data or encryption keys
- **Bitwarden SDK**: Rust-based cryptographic SDK handling all encryption/decryption operations
- **DataState**: Wrapper for streaming data states (Loading, Loaded, Pending, Error, NoNetwork)
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
### Module Organization
---
**Core Library Modules:**
- **`:core`** - Common utilities and managers shared across multiple modules
- **`:data`** - Data sources, database, data repositories
- **`:network`** - Networking interfaces, API clients, network utilities
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
## Architecture
**Application Modules:**
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
```
User Request (UI Action)
|
Screen (Compose)
|
ViewModel (State/Action/Event)
|
Repository (Business Logic)
|
+----+----+----+
| | | |
Disk Network SDK
| | |
Room Retrofit Bitwarden
DB APIs Rust SDK
```
**Specialized Library Modules:**
- **`:authenticatorbridge`** - Communication bridge between :authenticator and :app
- **`:annotation`** - Custom annotations for code generation (Hilt, Room, etc.)
- **`:cxf`** - Android Credential Exchange (CXF/CXP) integration layer
### Key Principles
### Patterns Enforcement
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
2. **State Hoisting to ViewModel**: All state that affects behavior must live in the ViewModel's state
3. **Interface-Based DI**: All implementations use interface/`...Impl` pairs with Hilt injection
4. **Encryption by Default**: All sensitive data encrypted via SDK before storage
- **MVVM + UDF**: ViewModels with StateFlow, Compose UI
- **Hilt DI**: Interface injection, @HiltViewModel, @Inject constructor
- **Testing**: JUnit 5, MockK, Turbine for Flow testing
- **Error Handling**: Sealed Result types, no throws in business logic
### Core Patterns
## Security Requirements
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt`.
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data.
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
**Every change must consider:**
- Zero-knowledge architecture preservation
- Proper encryption key handling (Android Keystore)
- Input validation and sanitization
- Secure data storage patterns
- Threat model implications
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
## Workflow Practices
---
### Before Implementation
## Development Guide
1. Read relevant architecture documentation
2. Search for existing patterns to follow
3. Identify affected modules and dependencies
4. Consider security implications
### Workflow Skills
### During Implementation
> **Quick start**: Use `/plan-android-work <task>` to refine requirements and plan,
> then `/work-on-android <task>` for implementation,
> then `/review-android <PR#>` to review the result.
1. Follow existing code style in surrounding files
2. Write tests alongside implementation
3. Add KDoc to all public APIs
4. Validate against architecture guidelines
Planning: 12 | Implementation: 37 | Review & PR: 810
### After Implementation
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
2. `planning-android-implementation` - Architecture design and phased task breakdown
3. `implementing-android-code` - Patterns, gotchas, and templates for writing code
4. `testing-android-code` - Test patterns and templates for verifying code
5. `build-test-verify` - Build, test, lint, and deploy commands
6. `perform-android-preflight-checklist` - Quality gate before committing
7. `committing-android-changes` - Commit message format and pre-commit workflow
8. `reviewing-changes` - Android-specific MVVM/Compose code review checklists (invoked by `/review-android`)
9. `/review-android` - Full review workflow: PR context gathering → Android checklist → output
10. `creating-android-pull-request` - PR creation workflow and templates
1. Ensure all tests pass
2. Verify compilation succeeds
3. Review security considerations
4. Update relevant documentation
---
## Security Rules
**MANDATORY - These rules have no exceptions:**
1. **Zero-Knowledge Architecture**: Never transmit unencrypted vault data or master passwords to the server. All encryption happens client-side via the Bitwarden SDK.
2. **No Plaintext Key Storage**: Encryption keys must be stored using Android Keystore (biometric unlock) or encrypted with PIN/master password.
3. **Sensitive Data Cleanup**: On logout, all sensitive data must be cleared from memory and storage via `UserLogoutManager.logout()`.
4. **Input Validation**: Validate all user inputs before processing, especially URLs and credentials.
5. **SDK Isolation**: Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user crypto context leakage.
---
## Code Style & Standards
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
- **KDoc**: Required for all public APIs
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
---
## Anti-Patterns
**Avoid these:**
- Creating new patterns when established ones exist
- Exception-based error handling in business logic
- Direct dependency access (use DI)
- Mutable state in ViewModels (use StateFlow)
- Missing null safety handling
- Undocumented public APIs
- Tight coupling between modules
In addition to the Key Principles above, follow these rules:
## Communication & Decision-Making
### DO
- Map async results to internal actions before updating state
- Inject `Clock` for time-dependent operations
- Return early to reduce nesting
Always clarify ambiguous requirements before implementing. Use specific questions:
- "Should this use [Approach A] or [Approach B]?"
- "This affects [X]. Proceed or review first?"
- "Expected behavior for [specific requirement]?"
### DON'T
- Update state directly inside coroutines (use internal actions)
- Use `any` types or suppress null safety
- Catch generic `Exception` (catch specific types)
- Use `e.printStackTrace()` (use Timber logging)
- Create new patterns when established ones exist
- Skip KDoc for public APIs
Defer high-impact decisions to the user:
- Architecture/module changes, public API modifications
- Security mechanisms, database migrations
- Third-party library additions
---
## Reference Documentation
## Quick Reference
Critical resources:
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
**Do not duplicate information from these files - reference them instead.**
- **Code style**: Full rules: `docs/STYLE_AND_BEST_PRACTICES.md`
- **Before writing code**: Use `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and templates
- **Before writing tests**: Use `testing-android-code` skill for test patterns and templates
- **Building/testing**: Use `build-test-verify` skill | App tests: `./gradlew app:testStandardDebugUnitTest`
- **Before committing**: Use `perform-android-preflight-checklist` skill, then `committing-android-changes` skill for message format
- **Code review**: Use `/review-android` for the full review workflow; `reviewing-changes` skill for checklist-only
- **Creating PRs**: Use `creating-android-pull-request` skill for PR workflow and templates
- **Troubleshooting**: See `docs/TROUBLESHOOTING.md`
- **Architecture**: `docs/ARCHITECTURE.md` | [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/)

View File

@@ -0,0 +1,58 @@
---
name: android-implementer
description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation."
model: opus
color: green
tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android)
---
You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase.
## First Action: Invoke `/work-on-android`
**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence.
Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance.
## Self-Review Protocol
At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead:
```
--- Phase Review: [Phase Name] ---
Status: APPROVED / NEEDS REFINEMENT
Findings: [brief assessment]
Action: [Proceeding to next phase / Iterating on: X]
---
```
If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns.
**Review criteria by phase:**
- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list?
- **Testing**: Covers happy path, error cases, and edge cases?
- **Build & Verify**: All tests pass? No compilation errors or warnings?
- **Preflight**: Would this pass code review by a senior engineer?
- **Commit**: Message clear, properly formatted, and accurate?
## Decision-Making Framework
- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing.
- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module.
- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task.
- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests.
- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts.
## Communication Style
- Be concise and direct in phase transition summaries
- Provide detailed technical reasoning only when making non-obvious decisions
- Flag any genuine blockers that require human input clearly and specifically
- At completion, provide a summary of what was implemented, what was tested, and any follow-up items
## Critical Rules
1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve.
2. **Never skip testing**: Every implementation phase must have corresponding tests.
3. **Never invent new patterns**: Use established codebase patterns. Search for examples first.
4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why.

View File

@@ -0,0 +1,119 @@
---
description: Guided requirements refinement and implementation planning for Bitwarden Android
argument-hint: <Jira ticket (PM-12345), Confluence URL, or free-text description>
---
# Android Planning Workflow
You are guiding the developer through requirements refinement and implementation planning for the Bitwarden Android project. The input to plan from is:
**Input**: $ARGUMENTS
## Prerequisites
- **Jira/Confluence access**: Fetching tickets and wiki pages requires the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin. If the plugin is not installed, Jira ticket IDs and Confluence URLs cannot be fetched automatically.
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable. If starting from a partially completed plan, skip to the appropriate phase.
### Phase 1: Ingest Requirements
Parse the input to detect and fetch all available sources:
**Source Detection Rules:**
- **Jira tickets** (patterns like `PM-\d+`, `BWA-\d+`, `EC-\d+`): Fetch via `get_issue` and `get_issue_comments`. Also fetch linked issue summaries (parent epic, sub-tasks, blockers) for context.
- **Confluence URLs** (containing `atlassian.net/wiki` or confluence page IDs): Extract page ID and fetch via `get_confluence_page`. If the page is a parent page, fetch child pages via `get_child_pages` and ask the user which are relevant.
- **Free text**: Treat as direct requirements — no fetching needed.
- **Multiple inputs**: All are first-class sources. Fetch each independently and consolidate.
- **Tool unavailable**: If `get_issue`, `get_confluence_page`, or other Atlassian tools are not available, inform the user that the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required and prompt them to install and configure it. In the meantime, ask the user to paste the relevant content directly. Treat pasted content as free-text input.
**Present a structured summary:**
1. Sources identified and fetched (with links)
2. Raw requirements extracted from each source
3. Initial scope assessment (small / medium / large)
**Edge cases:**
- Jira ticket with no description → flag as critical gap that Phase 2 must address
- Multiple tickets → fetch all, consolidate, flag any contradictions
- Ticket + free text → both treated as first-class; free text supplements ticket
**Gate**: User confirms the summary is complete and may add additional sources or context before proceeding.
### Phase 2: Refine Requirements
Invoke the `refining-android-requirements` skill and use it to perform gap analysis on the raw requirements from Phase 1.
The skill will:
1. Consolidate all sources into a working document
2. Evaluate requirements against a structured rubric (functional, technical, security, UX, cross-cutting)
3. Present categorized gaps as blocking or non-blocking questions
4. After user answers, produce a structured specification with numbered IDs
**Gate**: User approves the refined specification. They may request changes or provide additional answers.
### Phase 3: Plan Implementation
Invoke the `planning-android-implementation` skill and use it to design the implementation approach based on the refined spec from Phase 2.
The skill will:
1. Classify the change type
2. Explore the codebase for reference implementations and integration points
3. Design the architecture with component relationships
4. Produce a file inventory and phased implementation plan
5. Assess risks and define verification criteria
**Gate**: User reviews the implementation plan and may request changes to architecture, phasing, or scope.
### Phase 4: Finalize & Save
Merge the outputs from Phase 2 (specification) and Phase 3 (implementation plan) into a single design document using this template:
```markdown
# [Feature Name] — Design Document
**Feature**: [concise description]
**Date**: [current date]
**Status**: Ready for Implementation
**Jira**: [ticket ID if available]
**Sources**: [list of all input sources with links]
---
## Requirements Specification
[Full output from Phase 2 — the refined specification with numbered IDs]
---
## Implementation Plan
[Full output from Phase 3 — architecture, file inventory, phases, risks]
---
## Executing This Plan
To implement this plan, run:
/work-on-android [ticket or feature reference]
Reference this design document during implementation for architecture decisions,
file locations, and phase ordering.
```
**Save the document:**
- With ticket: `.claude/outputs/plans/PM-XXXXX-FEATURE-NAME-PLAN.md`
- Without ticket: `.claude/outputs/plans/FEATURE-NAME-PLAN.md`
- Feature name should be uppercase with hyphens (e.g., `BIOMETRIC-TIMEOUT-CONFIG-PLAN.md`)
- Create the output directory if it does not exist
**On completion**: Present the saved file path and remind the user they can execute the plan with `/work-on-android`.
## Guidelines
- Be explicit about which phase you are in at all times.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- When fetching from Jira/Confluence, summarize what was found rather than dumping raw content.
- Questions in Phase 2 should be specific and actionable, not generic.
- The implementation plan in Phase 3 should reference concrete files in the codebase, not abstract descriptions.

View File

@@ -0,0 +1,72 @@
---
description: Guided Android code review workflow through context gathering, Android-specific review, and output
argument-hint: [PR# | PR URL | "local"]
---
# Android Code Review Workflow
You are guiding the developer through a comprehensive Android code review for the Bitwarden Android project.
**Input**: $ARGUMENTS
## Prerequisites
- **Jira/Confluence access**: The `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required to fetch linked Jira tickets. If unavailable, skip ticket context.
- **GitHub CLI**: Required for fetching PR metadata. Verify with `gh auth status`.
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable.
### Phase 1: Ingest
Parse the input to determine review context:
**Source Detection Rules:**
- **PR number** (`123`, `PR #123`, `https://github.com/.../pull/123`): Extract the numeric ID. Fetch PR metadata via `gh pr view <N> --json title,body,headRefName,baseRefName,author,files`. Fetch existing review threads to avoid duplicate comments via `gh api graphql` with `reviewThreads(first: 100)`.
- **"local"** or no argument: Review current branch changes via `git diff main...HEAD` and `git log main...HEAD --oneline --no-merges`.
- **No input**: Ask the user whether to review a PR (provide number/URL) or local branch changes.
**Additional context:**
- Detect Jira ticket references in PR title/body (patterns like `PM-\d+`, `BWA-\d+`). Fetch via `get_issue` if the MCP plugin is available.
- Summarize what was fetched rather than dumping raw content.
**Present a structured summary:**
1. What is being reviewed (PR title/number, branch, or local changes description)
2. Jira ticket context if found (summary and acceptance criteria)
3. Files changed (count and modules affected)
4. Existing review thread count (PR reviews only — avoids duplicate comments)
**Gate**: User confirms the summary is complete before proceeding.
### Phase 2: Review
Invoke the `reviewing-changes` skill and use it to perform the Android-specific code review. Use the PR context from Phase 1 (change type, files affected, modules, Jira requirements) to inform the skill's change type detection and checklist selection.
The skill will:
1. Detect the change type based on files and PR context from Phase 1
2. Load the appropriate type-specific checklist
3. Execute the multi-pass review strategy
4. Consult reference materials as needed
**Before advancing**: Share a summary of key findings (critical issues if any, overall assessment) and confirm the user is ready to output the review.
### Phase 3: Output
Write the completed review to local files:
- `review-summary.md` — Overall assessment (APPROVE / REQUEST CHANGES) plus critical issues list
- `review-inline-comments.md` — All inline findings with `<details>` tags
Follow the exact output format from `.claude/skills/reviewing-changes/examples/review-outputs.md`.
For PR reviews: offer to post the review to GitHub using `gh pr review <N> --comment -b "$(cat review-summary.md)"` for the summary. For inline comments, use the GitHub API or the `bitwarden-code-review` plugin if installed.
**Before advancing**: Confirm the files were written successfully and ask if the user wants to post to GitHub (PR reviews only).
## Guidelines
- Be explicit about which phase you are in at all times.
- Never proceed to another phase without user confirmation.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- If starting from a partially completed review (e.g., review already written), skip to the appropriate phase.

View File

@@ -0,0 +1,66 @@
---
description: Guided Android development workflow through all lifecycle phases
argument-hint: <task description, plan, or Jira ticket reference>
---
# Android Development Workflow
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
**Task**: $ARGUMENTS
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
### Phase 1: Implement
Invoke the `implementing-android-code` skill and use it to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
### Phase 2: Test
Invoke the `testing-android-code` skill and use it to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
### Phase 3: Build & Verify
Invoke the `build-test-verify` skill to run tests, lint, and detekt. Ensure everything passes.
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
**Before advancing**: Report build/test/lint results and confirm readiness for self-review.
### Phase 4: Self-Review
Invoke the `perform-android-preflight-checklist` skill to perform a quality gate check on all changes. Address any issues found.
**Before advancing**: Share the self-review results and confirm readiness to commit.
### Phase 5: Commit
Invoke the `committing-android-changes` skill to stage and commit the changes with a properly formatted commit message.
**Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here.
### Phase 6: Review
**Pre-requisites:**
- `bitwarden-code-review` from the [Bitwarden Plugin Marketplace](https://github.com/bitwarden/ai-plugins) must be installed in order to perform this phase. If it is not installed prompt the user to install it, or skip the review phase.
Launch a subagent with the `/bitwarden-code-review:code-review-local` command to perform a **local** code review of the committed diff. Validate and address any issues found before proceeding.
**Before advancing**: Share review findings and confirm readiness for PR creation.
### Phase 7: Pull Request
Prompt the user to invoke the `creating-android-pull-request` skill to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
## Guidelines
- Be explicit about which phase you are in at all times.
- Never proceed to another phase without user confirmation.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- If starting from a partially completed task (e.g., code already written), skip to the appropriate phase.

View File

@@ -1,20 +1,3 @@
Use the `reviewing-changes` skill to review this pull request.
The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

14
.claude/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"attribution": {
"commit": "",
"pr": ""
},
"extraKnownMarketplaces": {
"bitwarden-marketplace": {
"source": {
"source": "github",
"repo": "bitwarden/ai-plugins"
}
}
}
}

View File

@@ -0,0 +1,152 @@
---
name: build-test-verify
version: 0.1.0
description: Build, test, lint, and deploy commands for the Bitwarden Android project. Use when running tests, building APKs/AABs, running lint/detekt, deploying, using fastlane, or discovering codebase structure. Triggered by "run tests", "build", "gradle", "lint", "detekt", "deploy", "fastlane", "assemble", "verify", "coverage".
---
# Build, Test & Verify
## Environment Setup
| Variable | Required | Description |
|----------|----------|-------------|
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages auth for SDK (`read:packages` scope) |
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
| Build types | - | `debug`, `beta`, `release` |
If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.properties` or environment and check connectivity to `maven.pkg.github.com`.
---
## Building
```bash
# Debug builds
./gradlew app:assembleDebug
./gradlew authenticator:assembleDebug
# Release builds (requires signing keys)
./gradlew app:assembleStandardRelease
./gradlew app:bundleStandardRelease
# F-Droid builds
./gradlew app:assembleFdroidRelease
```
---
## Running Tests
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
**IMPORTANT**: Always pipe test output through a filter that captures failures on the first run. Gradle suppresses detailed failure output by default, so use `2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30` to see pass/fail results and assertion details without needing a second run.
```bash
# App module tests (correct flavor!)
./gradlew app:testStandardDebugUnitTest 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
# Run specific test classes
./gradlew app:testStandardDebugUnitTest --tests "com.x8bit.bitwarden.SomeTest" 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
# Run all unit tests across all modules
./gradlew test
# Individual shared modules (no flavor needed)
./gradlew :core:test
./gradlew :data:test
./gradlew :network:test
./gradlew :ui:test
# Authenticator module
./gradlew authenticator:testStandardDebugUnitTest
```
### Reading Test Reports
If you need full failure details beyond what grep captures, check the HTML test report:
```bash
# After a test run, open the report at:
# app/build/reports/tests/testStandardDebugUnitTest/index.html
# Or read individual failure XML:
find app/build/test-results -name "*.xml" -exec grep -l "failure" {} \;
```
### Test Structure
```
app/src/test/ # App unit tests
app/src/testFixtures/ # App test utilities
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
```
### Test Quick Reference
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
- **Time Control**: Inject `Clock` for deterministic time testing
---
## Lint & Static Analysis
```bash
# Detekt (static analysis)
./gradlew detekt
# Android Lint
./gradlew lint
# Full validation suite (detekt + lint + tests + coverage)
./fastlane check
```
---
## Codebase Discovery
```bash
# Find existing Bitwarden UI components
find ui/src/main/kotlin/com/bitwarden/ui/platform/components/ -name "Bitwarden*.kt" | sort
# Find all ViewModels
grep -rl "BaseViewModel<" app/src/main/kotlin/ --include="*.kt"
# Find all Navigation files with @Serializable routes
find app/src/main/kotlin/ -name "*Navigation.kt" | sort
# Find all Hilt modules
find app/src/main/kotlin/ -name "*Module.kt" -path "*/di/*" | sort
# Find all repository interfaces
find app/src/main/kotlin/ -name "*Repository.kt" -not -name "*Impl.kt" -path "*/repository/*" | sort
# Find encrypted disk source examples
grep -rl "EncryptedPreferences" app/src/main/kotlin/ --include="*.kt"
# Find Clock injection usage
grep -rl "private val clock: Clock" app/src/main/kotlin/ --include="*.kt"
# Search existing strings before adding new ones
grep -n "search_term" ui/src/main/res/values/strings.xml
```
---
## Deployment & Versioning
**Version location**: `gradle/libs.versions.toml`
```toml
appVersionCode = "1"
appVersionName = "2025.11.1"
```
Pattern: `YEAR.MONTH.PATCH`
**Publishing channels**:
- **Play Store**: GitHub Actions workflow with signed AAB
- **F-Droid**: Dedicated workflow with F-Droid signing keys
- **Firebase App Distribution**: Beta testing

View File

@@ -0,0 +1,81 @@
---
name: committing-android-changes
version: 0.1.0
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
---
# Git Commit Conventions
## Commit Message Format
```
[PM-XXXXX] <type>: <imperative summary>
<optional body explaining why, not what>
```
### Rules
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
4. **Short summary**: Under 72 characters for the first line
5. **Body**: Explain the "why" not the "what" — the diff shows the what
### Type Keywords
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
### Example
```
[PM-12345] feat: Add biometric unlock timeout configuration
Users reported confusion about when biometric prompts appear.
This adds a configurable timeout setting to the security preferences.
```
### Followup Commits
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
```
Update error handling in login flow
```
---
## Pre-Commit Checklist
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
2. **Check lint**: `./gradlew detekt` on changed modules
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
---
## What NOT to Commit
- `.env` files or `user.properties` with real tokens
- Credential files or signing keystores
- Build outputs (`build/`, `*.apk`, `*.aab`)
- IDE-specific files (`.idea/` changes, `*.iml`)
- Large binary files
---
## Staging Best Practices
- **Stage specific files** by name rather than `git add -A` or `git add .`
- Put each file path on its own line for readability:
```bash
git add \
path/to/first/File.kt \
path/to/second/File.kt \
path/to/third/File.kt
```
- Review each file being staged to avoid accidentally including sensitive data
- Use `git status` (without `-uall` flag) to see the working tree state

View File

@@ -0,0 +1,64 @@
---
name: creating-android-pull-request
version: 0.1.0
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
---
# Create Pull Request
## PR Title Format
```
[PM-XXXXX] <type>: <short imperative summary>
```
**Examples:**
- `[PM-12345] feat: Add autofill support for passkeys`
- `[PM-12345] fix: Resolve crash during vault sync`
- `[PM-12345] refactor: Simplify authentication flow`
**Rules:**
- Include Jira ticket prefix
- Keep under 70 characters total
- Use imperative mood in the summary
**Type keywords** (triggers automatic `t:` label via CI):
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
---
## PR Body Template
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
---
## Pre-PR Checklist
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
2. **Lint clean**: Run `./gradlew detekt`
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
5. **Branch up to date**: Rebase on `main` if needed
---
## Creating the PR
```bash
# Ensure branch is pushed
git push -u origin <branch-name>
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
```
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
---
## Base Branch
- Default target: `main`
- Check with team if targeting a feature branch instead

View File

@@ -0,0 +1,481 @@
---
name: implementing-android-code
version: 0.1.2
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
---
# Implementing Android Code - Bitwarden Quick Reference
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
---
## Critical Patterns Reference
### A. ViewModel Implementation (State-Action-Event Pattern)
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
**Key Requirements:**
- Annotate with `@HiltViewModel`
- State class MUST be `@Parcelize data class : Parcelable`
- Implement `handleAction(action: A)` - MUST be synchronous
- Post internal actions from coroutines using `sendAction()`
- Save/restore state via `SavedStateHandle[KEY_STATE]`
- Private action handlers: `private fun handle*` naming convention
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
**Pattern Summary:**
```kotlin
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
) {
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
// Synchronous dispatch only
when (action) {
is Action.Click -> handleClick()
is Action.Internal.DataReceived -> handleDataReceived(action)
}
}
private fun handleClick() {
viewModelScope.launch {
val result = repository.fetchData()
sendAction(Action.Internal.DataReceived(result)) // Post internal action
}
}
private fun handleDataReceived(action: Action.Internal.DataReceived) {
mutableStateFlow.update { it.copy(data = action.result) }
}
}
```
**Reference:**
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
**Critical Gotchas:**
-**NEVER** update `mutableStateFlow` directly inside coroutines
-**ALWAYS** post internal actions from coroutines, update state in `handleAction()`
-**NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
-**ALWAYS** use `@Parcelize` on state classes for process death recovery
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
---
### B. Navigation Implementation (Type-Safe)
All navigation uses **type-safe routes** with kotlinx.serialization.
**Pattern Structure:**
1. `@Serializable` route data class with parameters
2. `...Args` helper class for extracting from `SavedStateHandle`
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
4. `NavController.navigateTo{Screen}()` extension for navigation calls
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
**Pattern Summary:**
```kotlin
@Serializable
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
}
fun NavController.navigateToExample(
userId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
}
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(onNavigateBack = onNavigateBack)
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
**Key Benefits:**
- ✅ Type safety: Compile-time errors for missing parameters
- ✅ No string literals in navigation code
- ✅ Automatic serialization/deserialization
- ✅ Clear contract for screen dependencies
---
### C. Screen/Compose Implementation
All screens follow consistent Compose patterns.
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
**Key Patterns:**
```kotlin
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
}
}
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.title),
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
// UI content
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
**Essential Requirements:**
- ✅ Use `hiltViewModel()` for dependency injection
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
- ✅ Use `EventsEffect(viewModel)` for one-shot events
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
**State Hoisting Rules:**
- **ViewModel state**: Data that needs to survive process death or affects business logic
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
---
### D. Data Layer Implementation
The data layer follows strict patterns for repositories, managers, and data sources.
**Interface + Implementation Separation (ALWAYS)**
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
**Pattern Summary:**
```kotlin
// Interface (injected via Hilt)
interface ExampleRepository {
suspend fun fetchData(id: String): ExampleResult
val dataFlow: StateFlow<DataState<ExampleData>>
}
// Implementation (NOT directly injected)
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
) : ExampleRepository {
override suspend fun fetchData(id: String): ExampleResult {
// NO exceptions thrown - return Result or sealed class
return exampleService.getData(id).fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
// Sealed result class (domain-specific)
sealed class ExampleResult {
data class Success(val data: ExampleData) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
}
```
**Reference:**
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
**Three-Layer Data Architecture:**
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
**Critical Rules:**
-**NEVER** throw exceptions in data layer
-**ALWAYS** use interface + `...Impl` pattern
-**ALWAYS** inject interfaces, never implementations
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
- ✅ Use `StateFlow` for continuously observed data
---
### E. UI Components
**Use Existing Components First:**
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
**Common Components:**
- `BitwardenFilledButton` - Primary action buttons
- `BitwardenOutlinedButton` - Secondary action buttons
- `BitwardenTextField` - Text input fields
- `BitwardenPasswordField` - Password input with show/hide
- `BitwardenSwitch` - Toggle switches
- `BitwardenTopAppBar` - Toolbar/app bar
- `BitwardenScaffold` - Screen container with scaffold
- `BitwardenBasicDialog` - Simple dialogs
- `BitwardenLoadingDialog` - Loading indicators
**Component Discovery:**
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
**When to Create New Reusable Components:**
- Component used in 3+ places
- Component needs consistent theming across app
- Component has semantic meaning (accessibility)
- Component has complex state management
**New Component Requirements:**
- Prefix with `Bitwarden`
- Accept themed colors/styles from `BitwardenTheme`
- Include preview composables for testing
- Support accessibility (content descriptions, semantics)
**String Resources:**
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
- Use typographic apostrophes and quotes to avoid escape characters: `youll` not `you\'ll`, `“word”` not `\"word\"`
- Reference strings via generated `BitwardenString` resource IDs
- Do not add strings to other modules unless explicitly instructed
---
### F. Security Patterns
**Encrypted vs Unencrypted Storage:**
**Template**: See [Security templates](templates.md#security-templates)
**Pattern Summary:**
```kotlin
class ExampleDiskSourceImpl(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
fun storeAuthToken(token: String) {
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
}
fun storeThemePreference(isDark: Boolean) {
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
}
}
```
**Android Keystore (Biometric Keys):**
- User-scoped encryption keys: `BiometricsEncryptionManager`
- Keys stored in Android Keystore (hardware-backed when available)
- Integrity validation on biometric state changes
**Input Validation:**
```kotlin
// Validation returns boolean, NEVER throws
interface RequestValidator {
fun validate(request: Request): Boolean
}
// Sanitization removes dangerous content
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
if (this.isNullOrBlank()) return null
// Sanitize and return safe value
}
```
**Security Checklist:**
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
-**NEVER** log sensitive data (passwords, tokens, vault items)
- ✅ Validate all user input before processing
- ✅ Use Timber for non-sensitive logging only
---
### G. Testing Patterns
**ViewModel Testing:**
**Template**: See [Testing templates](templates.md#testing-templates)
**Pattern Summary:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
val expectedResult = ExampleResult.Success(data = "test")
coEvery { mockRepository.fetchData(any()) } returns expectedResult
val viewModel = createViewModel()
viewModel.trySendAction(ExampleAction.ButtonClick)
viewModel.stateFlow.test {
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
}
}
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
repository = mockRepository,
)
}
```
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Key Testing Patterns:**
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
- ✅ Use `runTest` from `kotlinx.coroutines.test`
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
- ✅ Test both state changes and event emissions
- ✅ Test both success and failure Result paths
**Flow Testing with Turbine:**
```kotlin
// Test state and events simultaneously
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.Submit)
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
}
```
**MockK Quick Reference:**
```kotlin
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
every { diskSource.getData() } returns "cached" // Sync
coVerify { repository.fetchData("123") } // Verify
```
---
### H. Clock/Time Handling
All code needing current time must inject `Clock` for testability.
**Key Requirements:**
- ✅ Inject `Clock` via Hilt in ViewModels
- ✅ Pass `Clock` as parameter in extension functions
- ✅ Use `clock.instant()` to get current time
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
- ❌ Never use `mockkStatic` for datetime classes in tests
**Pattern Summary:**
```kotlin
// ViewModel with Clock
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
val timestamp = clock.instant()
}
// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
// Test with fixed clock
val FIXED_CLOCK = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
```
**Reference:**
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
**Critical Gotchas:**
-`Instant.now()` creates hidden dependency, non-testable
-`mockkStatic(Instant::class)` is fragile, can leak between tests
-`Clock.fixed(...)` provides deterministic test behavior
---
## Bitwarden-Specific Anti-Patterns
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
**NEVER update ViewModel state directly in coroutines**
- Post internal actions, update state synchronously in `handleAction()`
**NEVER inject `...Impl` classes**
- Only inject interfaces via Hilt
**NEVER create navigation without `@Serializable` routes**
- No string-based navigation, always type-safe
**NEVER use raw `Result<T>` in repositories**
- Use domain-specific sealed classes for better error handling
**NEVER make state classes without `@Parcelize`**
- All ViewModel state must survive process death
**NEVER skip `SavedStateHandle` persistence for ViewModels**
- Users lose form progress on process death
**NEVER forget `@IgnoredOnParcel` for passwords/tokens**
- Causes security vulnerability (sensitive data in parcel)
**NEVER use generic `Exception` catching**
- Catch specific exceptions only (`RemoteException`, `IOException`)
**NEVER call `Instant.now()` or `DateTime.now()` directly**
- Inject `Clock` via Hilt, use `clock.instant()` for testability
---
## Quick Reference
For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
**File Reference Format:**
When pointing to specific code, use: `file_path:line_number`
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)

View File

@@ -0,0 +1,636 @@
# Code Templates - Bitwarden Android
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
---
## ViewModel Template (State-Action-Event Pattern)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
### State Class
```kotlin
@Parcelize
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
val dialogState: DialogState? = null,
) : Parcelable {
/**
* Dialog states for the Example screen.
*/
sealed class DialogState : Parcelable {
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
val error: Throwable? = null,
) : DialogState()
@Parcelize
data class Loading(val message: Text) : DialogState()
}
}
```
### Event Sealed Class
```kotlin
/**
* One-shot UI events for the Example screen.
*/
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data class ShowToast(val message: Text) : ExampleEvent()
}
```
### Action Sealed Class (with Internal)
```kotlin
/**
* User and system actions for the Example screen.
*/
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class InputChanged(val input: String) : ExampleAction()
data object ErrorDialogDismiss : ExampleAction()
/**
* Internal actions dispatched by the ViewModel from coroutines.
*/
sealed class Internal : ExampleAction() {
data class ReceiveDataState(
val dataState: DataState<ExampleData>,
) : Internal()
data class ReceiveDataResult(
val result: ExampleResult,
) : Internal()
}
}
```
### ViewModel
```kotlin
private const val KEY_STATE = "state"
/**
* ViewModel for the Example screen.
*/
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val exampleRepository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toExampleArgs()
ExampleState(
data = args.itemId,
)
},
) {
init {
// Persist state for process death recovery
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// Collect repository flows as internal actions
exampleRepository.dataFlow
.map { ExampleAction.Internal.ReceiveDataState(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
when (action) {
ExampleAction.BackClick -> handleBackClick()
ExampleAction.SubmitClick -> handleSubmitClick()
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is ExampleAction.InputChanged -> handleInputChanged(action)
is ExampleAction.Internal.ReceiveDataState -> {
handleReceiveDataState(action)
}
is ExampleAction.Internal.ReceiveDataResult -> {
handleReceiveDataResult(action)
}
}
}
private fun handleBackClick() {
sendEvent(ExampleEvent.NavigateBack)
}
private fun handleErrorDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleSubmitClick() {
viewModelScope.launch {
val result = exampleRepository.submitData(state.data.orEmpty())
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
}
}
private fun handleInputChanged(action: ExampleAction.InputChanged) {
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
}
private fun handleReceiveDataState(
action: ExampleAction.Internal.ReceiveDataState,
) {
when (action.dataState) {
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = action.dataState.data.toString(),
)
}
}
is DataState.Loading -> {
mutableStateFlow.update { it.copy(isLoading = true) }
}
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
error = action.dataState.error,
),
)
}
}
else -> Unit
}
}
private fun handleReceiveDataResult(
action: ExampleAction.Internal.ReceiveDataResult,
) {
when (val result = action.result) {
is ExampleResult.Success -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = result.data,
)
}
}
is ExampleResult.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = result.message?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
```
---
## Navigation Template (Type-Safe Routes)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
```kotlin
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.feature.example
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* Route for the Example screen.
*/
@Serializable
@OmitFromCoverage
data class ExampleRoute(
val itemId: String,
val isEditMode: Boolean = false,
)
/**
* Args extracted from [SavedStateHandle] for the Example screen.
*/
@OmitFromCoverage
data class ExampleArgs(
val itemId: String,
val isEditMode: Boolean,
)
/**
* Extracts [ExampleArgs] from the [SavedStateHandle].
*/
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(
itemId = route.itemId,
isEditMode = route.isEditMode,
)
}
/**
* Navigate to the Example screen.
*/
fun NavController.navigateToExample(
itemId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(
route = ExampleRoute(
itemId = itemId,
isEditMode = isEditMode,
),
navOptions = navOptions,
)
}
/**
* Add the Example screen destination to the navigation graph.
*/
fun NavGraphBuilder.exampleDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(
onNavigateBack = onNavigateBack,
)
}
}
```
---
## Screen/Compose Template
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
```kotlin
package com.x8bit.bitwarden.ui.feature.example
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* The Example screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
is ExampleEvent.ShowToast -> {
// Handle toast
}
}
}
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
modifier = Modifier
.fillMaxSize(),
)
}
}
```
---
## Data Layer Template (Repository + Hilt Module)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
### Interface
```kotlin
/**
* Provides data operations for the Example feature.
*/
interface ExampleRepository {
/**
* Submits data and returns a typed result.
*/
suspend fun submitData(input: String): ExampleResult
/**
* Continuously observed data stream.
*/
val dataFlow: StateFlow<DataState<ExampleData>>
}
```
### Sealed Result Class
```kotlin
/**
* Domain-specific result for Example operations.
*/
sealed class ExampleResult {
data class Success(val data: String) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
```
### Implementation
```kotlin
/**
* Default implementation of [ExampleRepository].
*/
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
private val dispatcherManager: DispatcherManager,
) : ExampleRepository {
override val dataFlow: StateFlow<DataState<ExampleData>>
get() = // ...
override suspend fun submitData(input: String): ExampleResult {
return exampleService
.postData(input)
.fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
```
### Hilt Module
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
dispatcherManager: DispatcherManager,
): ExampleRepository = ExampleRepositoryImpl(
exampleDiskSource = exampleDiskSource,
exampleService = exampleService,
dispatcherManager = dispatcherManager,
)
}
```
---
## Security Templates
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
### Encrypted Disk Source (Module)
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleDiskModule {
@Provides
@Singleton
fun provideExampleDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ExampleDiskSource = ExampleDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
json = json,
)
}
```
### Encrypted Disk Source (Implementation)
```kotlin
/**
* Disk source for Example data using encrypted and unencrypted storage.
*/
class ExampleDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
private companion object {
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
const val UNENCRYPTED_PREF_KEY = "examplePreference"
}
override var authToken: String?
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
override var uiPreference: Boolean
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
}
```
---
## Testing Templates
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
### ViewModel Test
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository = mockk<ExampleRepository>()
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
@BeforeEach
fun setup() {
every { mockRepository.dataFlow } returns mutableDataFlow
}
@Test
fun `initial state should be correct when there is no saved state`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when there is a saved state`() {
val savedState = DEFAULT_STATE.copy(data = "saved")
val viewModel = createViewModel(state = savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick should call repository and update state on success`() = runTest {
val expected = ExampleResult.Success(data = "result")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
// Updated state after result
assertEquals(
DEFAULT_STATE.copy(data = "result", isLoading = false),
awaitItem(),
)
}
}
@Test
fun `SubmitClick should show error dialog on failure`() = runTest {
val expected = ExampleResult.Error(message = "Network error")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
val errorState = awaitItem()
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
}
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExampleAction.BackClick)
assertEquals(ExampleEvent.NavigateBack, awaitItem())
}
}
// Helper to create ViewModel with optional saved state
private fun createViewModel(
state: ExampleState? = DEFAULT_STATE,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to state),
),
exampleRepository = mockRepository,
)
companion object {
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
)
}
}
```
### Flow Testing with stateEventFlow
```kotlin
@Test
fun `SubmitClick should update state and emit event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.SubmitClick)
// Assert state change
assertEquals(
DEFAULT_STATE.copy(data = "data"),
stateFlow.awaitItem(),
)
// Assert event emission
assertEquals(
ExampleEvent.ShowToast("Success".asText()),
eventFlow.awaitItem(),
)
}
}
```

View File

@@ -0,0 +1,40 @@
---
name: labeling-android-changes
version: 0.1.0
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
---
# Labeling Changes
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
## Format
The type keyword appears after the Jira ticket prefix:
```
[PM-XXXXX] <type>: <imperative summary>
```
## Type Keywords
| Type | Label | Use for |
|------|-------|---------|
| `feat` | `t:feature` | New features or functionality |
| `fix` | `t:bug` | Bug fixes |
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
| `test` | `t:tech-debt` | Adding or updating tests |
| `perf` | `t:tech-debt` | Performance improvements |
| `docs` | `t:docs` | Documentation changes |
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
| `deps` | `t:deps` | Dependency updates |
| `llm` | `t:llm` | LLM/Claude configuration changes |
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
| `misc` | `t:misc` | Changes that do not fit other categories |
## Selecting a Type
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.

View File

@@ -0,0 +1,37 @@
---
name: perform-android-preflight-checklist
version: 0.1.0
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
---
# Self-Review Checklist
Run through this checklist before committing or opening a PR.
## Tests
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
- [ ] New code has corresponding test coverage
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
## Code Quality
- [ ] Lint/detekt clean: `./gradlew detekt`
- [ ] No unintended file changes (`git diff` review)
- [ ] KDoc on all new public APIs
- [ ] No TODO comments left behind (or they reference a ticket)
## Security
- [ ] No plaintext keys, tokens, or secrets in code
- [ ] User input validated before processing
- [ ] Sensitive data uses encrypted storage patterns
- [ ] No logging of sensitive data (passwords, keys, tokens)
## Bitwarden Patterns
- [ ] String resources in `:ui` module with typographic quotes
- [ ] Navigation route is `@Serializable` and registered in graph
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
- [ ] Async results mapped through internal actions (not direct state updates)
## Files
- [ ] No accidental `.idea/`, build output, or generated files staged
- [ ] No credential files or `.env` files included

View File

@@ -0,0 +1,191 @@
---
name: planning-android-implementation
version: 0.1.0
description: Architecture design and phased implementation planning for Bitwarden Android. Use when planning implementation, designing architecture, creating file inventories, or breaking features into phases. Triggered by "plan implementation", "architecture design", "implementation plan", "break this into phases", "what files do I need", "design the architecture".
---
# Implementation Planning
This skill takes a refined specification (ideally from the `refining-android-requirements` skill) and produces a phased implementation plan with architecture design, file inventory, and risk assessment.
**Prerequisite**: A clear set of requirements. If requirements are vague or incomplete, invoke the `refining-android-requirements` skill first.
---
## Step 1: Classify Change
Determine the change type to guide scope and planning depth:
| Type | Description | Typical Scope |
|------|-------------|---------------|
| **New Feature** | Entirely new functionality, screens, or flows | New files + modifications, multi-phase |
| **Enhancement** | Extending existing feature with new capabilities | Mostly modifications, 1-2 phases |
| **Bug Fix** | Correcting incorrect behavior | Targeted modifications, single phase |
| **Refactoring** | Restructuring without behavior change | Modifications only, migration-aware |
| **Infrastructure** | Build, CI, tooling, or dependency changes | Config files, minimal code changes |
State the classification and rationale before proceeding.
---
## Step 2: Codebase Exploration
Search the codebase to find reference implementations and integration points. Use the discovery commands from the `build-test-verify` skill as needed.
### Find Pattern Anchors
Identify 2-3 existing files that serve as templates for the planned work:
```
**Pattern Anchors:**
1. [file path] — [why this is a good reference]
2. [file path] — [why this is a good reference]
3. [file path] — [why this is a good reference]
```
### Map Integration Points
Identify files that must be modified to integrate the new work:
- **Navigation**: Nav graph registrations, route definitions
- **Dependency Injection**: Hilt modules, `@Provides` / `@Binds` functions
- **Data Layer**: Repository interfaces, data source interfaces, Room DAOs
- **API Layer**: Retrofit service interfaces, request/response models
- **Feature Flags**: Feature flag definitions and checks
- **Managers**: Single-responsibility data layer classes (see `docs/ARCHITECTURE.md` Managers section)
- **Test Fixtures**: Shared test utilities in `src/testFixtures/` directories
- **Product Flavor Source Sets**: Code in `src/standard/` vs `src/main/` for Play Services dependencies
### Document Existing Patterns
Note the specific patterns used by the pattern anchors:
- State class structure (sealed class, data class fields)
- Action/Event naming conventions
- Repository method signatures and return types
- Test structure and assertion patterns
---
## Step 3: Architecture Design
Produce an ASCII diagram showing component relationships for the planned work:
```
┌─────────────────┐
│ Screen │ ← Compose UI
│ (Composable) │
└────────┬────────┘
│ State / Action / Event
┌────────▼────────┐
│ ViewModel │ ← Business logic orchestration
└────────┬────────┘
│ Repository calls
┌────────▼────────┐
│ Repository │ ← Data coordination (sealed class results)
└───┬────┬────┬───┘
│ │ │
┌───▼───┐ │ ┌─▼──────┐
│Manager│ │ │Manager │ ← Single-responsibility (optional)
└───┬───┘ │ └─┬──────┘
│ │ │
┌───▼─────▼───▼────┐
│ Data Sources │ ← Raw data (Result<T>, never throw)
└─┬────┬────┬──────┘
│ │ │
Room Retrofit SDK
```
Adapt the diagram to show the actual components planned. _Consult `docs/ARCHITECTURE.md` for full data layer patterns and conventions._
### Design Decisions
Document key architectural decisions in a table:
| Decision | Resolution | Rationale |
|----------|-----------|-----------|
| [What needed deciding] | [What was chosen] | [Why] |
---
## Step 4: File Inventory
### Files to Create
| File Path | Type | Pattern Reference |
|-----------|------|-------------------|
| [full path] | [ViewModel / Screen / Repository / etc.] | [pattern anchor file] |
**Include in file inventory:**
- `...Navigation.kt` files for new screens
- `...Module.kt` Hilt module files for new DI bindings
- Paired test files (`...Test.kt`) for each new class
### Files to Modify
| File Path | Change Description | Risk Level |
|-----------|-------------------|------------|
| [full path] | [what changes] | Low / Medium / High |
**Risk levels:**
- **Low**: Additive changes (new entries in nav graph, new bindings in Hilt module)
- **Medium**: Modifying existing logic (adding parameters, new branches)
- **High**: Changing interfaces, data models, or shared utilities
---
## Step 5: Implementation Phases
Break the work into sequential phases. Each phase should be independently testable and committable.
**Phase ordering principle**: Foundation → SDK/Data → Network → UI (tests accompany each phase)
For each phase:
```markdown
### Phase N: [Name]
**Goal**: [What this phase accomplishes]
**Files**:
- Create: [list]
- Modify: [list]
**Tasks**:
1. [Specific implementation task]
2. [Specific implementation task]
3. ...
**Verification**:
- [Test command or manual verification step]
**Skills**: [Which workflow skills apply — e.g., `implementing-android-code`, `testing-android-code`]
```
### Phase Guidelines
- Each phase should be small enough to be independently testable and committable
- Tests are written within the same phase as the code they verify (not deferred to a "testing phase")
- UI phases come after their data dependencies are in place
- If a phase has more than 5 tasks, consider splitting it
---
## Step 6: Risk & Verification
### Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle] |
### Verification Plan
**Automated Verification:**
- Unit test commands (from `build-test-verify` skill)
- Lint/detekt commands
- Build verification
**Manual Verification:**
- [Specific manual test scenarios]
- [Edge cases to manually verify]
- Verify ViewModel state survives process death (test via `SavedStateHandle` persistence and `Don't keep activities` developer option)

View File

@@ -0,0 +1,181 @@
---
name: refining-android-requirements
version: 0.1.0
description: Requirements gap analysis and structured specification for Bitwarden Android. Use when refining requirements, analyzing specs, identifying gaps, or producing structured specifications from tickets or descriptions. Triggered by "refine requirements", "gap analysis", "spec review", "requirements analysis", "what's missing from this spec", "analyze this ticket".
---
# Requirements Refinement
This skill takes raw requirements (from Jira tickets, Confluence pages, or free-text descriptions) and produces a structured, implementation-ready specification through systematic gap analysis.
**Key principle**: This skill identifies gaps and produces specifications. It does NOT propose solutions or architecture — that is the responsibility of the `planning-android-implementation` skill.
---
## Step 1: Source Consolidation
Combine all input sources into a single working document. For each requirement, note its source:
```
- [Source: PM-12345] User must be able to configure timeout
- [Source: Confluence] Timeout range is 1-60 minutes
- [Source: User] Default timeout should be 15 minutes
```
Flag any contradictions between sources for immediate resolution.
---
## Step 2: Gap Analysis
Evaluate the consolidated requirements against the following 5-category rubric. For each category, check every item and note whether it is **covered**, **partially covered**, or **missing**.
### A. Functional Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| User actions defined? | What specific user actions trigger this feature? |
| All states covered? (empty, loading, error, success) | What should the user see in [empty/loading/error] state? |
| Edge cases identified? | What happens when [boundary condition]? |
| Cancellation/back navigation flows? | Can the user cancel mid-flow? What happens to partial data? |
| Input validation rules? | What are the valid ranges/formats for [input]? |
| Success/failure criteria? | How does the user know the operation succeeded or failed? |
| Offline behavior? | What happens if this is attempted offline? |
### B. Technical Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Module scope identified? (`:app`, `:authenticator`, shared) | Which module(s) does this feature belong to? |
| SDK dependencies? | Does this require Bitwarden SDK operations? Which ones? |
| Data storage approach? (Room, DataStore, in-memory) | Where is the data for this feature persisted? |
| Network API endpoints? | Which API endpoints are involved? Are they existing or new? |
| Process death handling? | What state needs to survive process death? |
| Migration requirements? | Does existing data need migration? |
| Feature flag needed? | Should this be behind a feature flag for staged rollout? |
| Product flavors (standard vs fdroid)? | Does this feature depend on Google Play Services? Available on F-Droid? |
| Data layer tier? | Does this need a new Manager (single-responsibility) or only Repository/DataSource? Consult `docs/ARCHITECTURE.md` Data Layer section. |
| Streaming vs discrete data? | Is data continuously observed (`DataState<T>` + `StateFlow`) or a one-shot operation (custom sealed class)? See `docs/ARCHITECTURE.md` Repositories section. |
### C. Security Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Data sensitivity classified? | What sensitivity level does this data have? (vault-level, account-level, non-sensitive) |
| Storage encryption required? | Must this data be encrypted at rest? Via SDK or Android Keystore? |
| Logout cleanup behavior? | What must be cleared when the user logs out? |
| Auth-gating? | Does accessing this feature require active authentication? |
| Input sanitization? | Are there URL or credential inputs that need validation? |
| Sensitive data in ViewModel state? | Will passwords, tokens, or keys appear in state? Must use `@IgnoredOnParcel`. See `implementing-android-code` skill Section F. |
| SDK crypto context isolation? | Does this use vault encryption? Must use `ScopedVaultSdkSource` for multi-account safety. See CLAUDE.md Security Rules. |
### D. UX/UI Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| UI copy/strings defined? | What text should appear for [label/button/message]? |
| Error messages specified? | What should the error message say when [failure case]? |
| Loading states designed? | Should loading show a spinner, skeleton, or shimmer? |
| Navigation flow clear? | Where does the user go after [action]? Back stack behavior? |
| Accessibility considerations? | Are there content descriptions or focus order requirements? |
| Toast/snackbar/dialog for feedback? | What feedback mechanism for [action result]? |
### E. Cross-Cutting Concerns
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Multi-account behavior? | How does this behave with multiple accounts? Per-account or global? |
| Backwards compatibility? | Does this affect existing users? Migration path? |
| Feature flag strategy? | Is this behind a server-side or local feature flag? |
| Analytics/logging? | Are there analytics events to track? |
| Bitwarden Authenticator impact? | Does this affect the `:authenticator` module? |
| F-Droid compatibility? | Does this degrade gracefully without Google Play Services (no push notifications, no Play Integrity)? |
---
## Step 3: Present Gaps
Organize all identified gaps into two categories:
### Blocking Questions
Questions that **must** be answered before implementation can begin because they change the architecture, data model, or core flow.
Format each question as:
```
**G[N]** ([Category]) — [Question text]
Context: [Why this matters / what depends on the answer]
```
### Non-Blocking Questions
Questions that have **reasonable defaults** and can be resolved during implementation. Note the assumed default.
Format each question as:
```
**G[N]** ([Category]) — [Question text]
Default assumption: [What we'll assume if not answered]
Context: [Why this matters]
```
---
## Step 4: Produce Specification
After the user answers blocking questions (and optionally non-blocking ones), produce a structured specification:
```markdown
## Overview
[1-2 paragraph summary of the feature, its purpose, and scope]
## Functional Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| FR1 | [requirement] | [source] | [any notes] |
| FR2 | ... | ... | ... |
## Technical Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| TR1 | [requirement] | [source] | [any notes] |
| TR2 | ... | ... | ... |
## Security Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| SR1 | [requirement] | [source] | [any notes] |
## UX Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| UX1 | [requirement] | [source] | [any notes] |
## Open Items
Non-blocking items with assumed defaults that may be revisited:
| ID | Question | Assumed Default | Category |
|----|----------|----------------|----------|
| G[N] | [question] | [default] | [category] |
## Source Documentation
| Source | Type | Link |
|--------|------|------|
| [name] | Jira / Confluence / User-provided | [link if available] |
```
### Output Guidelines
- Requirements use numbered IDs (FR1, TR1, SR1, UX1) for traceability through implementation
- Each requirement cites its source (ticket, page, or user-provided)
- Technical requirements use table format for structured key/value data
- Interface signatures are included as fenced code blocks when applicable
- Open items preserve the gap ID (G[N]) for cross-referencing

View File

@@ -1,110 +1,80 @@
---
name: reviewing-changes
description: Performs comprehensive code reviews for Bitwarden Android projects, verifying architecture compliance, style guidelines, compilation safety, test coverage, and security requirements. Use when reviewing pull requests, checking commits, analyzing code changes, verifying Bitwarden coding standards, evaluating MVVM patterns, checking Hilt DI usage, reviewing security implementations, or assessing test coverage. Automatically invoked by CI pipeline or manually for interactive code reviews.
description: Android-specific code review checklist and MVVM/Compose pattern validation for Bitwarden Android — use this for any review task, even if the user doesn't explicitly ask for a "checklist". Detects change type automatically and loads the right review strategy (feature additions, bug fixes, UI refinements, refactoring, dependency updates, infrastructure). Triggered by "review PR", "review changes", "review this code", "check this code", "Android review", code review requests on Kotlin/ViewModel/Composable/Repository/Gradle files, or any time someone asks to look at a diff, PR, or code changes in bitwarden/android.
---
# Reviewing Changes
# Reviewing Changes - Android Additions
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
## Instructions
Follow this process to review code changes for Bitwarden Android:
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
### Step 1: Understand Context
### Step 1: Retrieve Additional Details
Start with high-level assessment of the change's purpose and approach. Read PR/commit descriptions and understand what problem is being solved.
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
### Step 2: Verify Compliance
If pull request title and message do not provide enough context, request additional details from the reviewer:
- Link a JIRA ticket
- Associate a GitHub issue
- Link to another pull request
- Add more detail to the PR title or body
Systematically check each area against Bitwarden standards documented in `CLAUDE.md`:
**Android metadata checks** — flag as ❓ if any of these are missing:
- PR includes `*Screen.kt` or Composable changes but has no screenshots
- PR adds new `ViewModel` or `Repository` but has no test plan or test file changes
1. **Architecture**: Follow patterns in `docs/ARCHITECTURE.md`
- MVVM + UDF (ViewModels with `StateFlow`, Compose UI)
- Hilt DI (interface injection, `@HiltViewModel`)
- Repository pattern and proper data flow
### Step 2: Detect Change Type with Android Refinements
2. **Style**: Adhere to `docs/STYLE_AND_BEST_PRACTICES.md`
- Naming conventions, code organization, formatting
- Kotlin idioms (immutability, null safety, coroutines)
Use the base change type detection from the agent, with Android-specific refinements:
3. **Compilation**: Analyze for potential build issues
- Import statements and dependencies
- Type safety and null safety
- API compatibility and deprecation warnings
- Resource references and manifest requirements
**Android-specific patterns:**
- **Feature Addition**: New `ViewModel`, new `Repository`, new `@Composable` functions, new `*Screen.kt` files
- **UI Refinement**: Changes only in `*Screen.kt`, `*Composable.kt`, `ui/` package files
- **Infrastructure**: Changes to `.github/workflows/`, `gradle/`, `build.gradle.kts`, `libs.versions.toml`
- **Dependency Update**: Changes only to `libs.versions.toml` or `build.gradle.kts` with version bumps
4. **Testing**: Verify appropriate test coverage
- Unit tests for business logic and utility functions
- Integration tests for complex workflows
- UI tests for user-facing features when applicable
- Test coverage for edge cases and error scenarios
### Step 3: Load Appropriate Checklist
5. **Security**: Given Bitwarden's security-focused nature
- Proper handling of sensitive data
- Secure storage practices (Android Keystore)
- Authentication and authorization patterns
- Data encryption and decryption flows
- Zero-knowledge architecture preservation
Based on detected type, read the relevant checklist file:
### Step 3: Document Findings
- **Dependency Update** → `checklists/dependency-update.md` (expedited review)
- **Bug Fix** → `checklists/bug-fix.md` (focused review)
- **Feature Addition** → `checklists/feature-addition.md` (comprehensive review)
- **UI Refinement** → `checklists/ui-refinement.md` (design-focused review)
- **Refactoring** → `checklists/refactoring.md` (pattern-focused review)
- **Infrastructure** → `checklists/infrastructure.md` (tooling-focused review)
Identify specific violations with `file:line_number` references. Be precise about locations.
The checklist provides:
- Multi-pass review strategy
- Type-specific focus areas
- What to check and what to skip
- Structured thinking guidance
### Step 4: Provide Recommendations
### Step 4: Execute Review Following Checklist
Give actionable recommendations for improvements. Explain why changes are needed and suggest specific solutions.
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
### Step 5: Flag Critical Issues
### Step 5: Consult Android Reference Materials As Needed
Highlight issues that must be addressed before merge. Distinguish between blockers and suggestions.
Load reference files only when needed for specific questions:
### Step 6: Acknowledge Quality
- **Re-reviews** → invoke `reviewing-incremental-changes` agent skill; scope to changed lines only, do not flag new issues in unchanged code
- **Issue prioritization** → `reference/priority-framework.md` (Critical vs Suggested vs Optional)
- **Phrasing feedback** → `reference/review-psychology.md` (questions vs commands, I-statements)
- **Architecture questions** → `reference/architectural-patterns.md` (MVVM, Hilt DI, module org, error handling)
- **Security questions (quick reference)** → `reference/security-patterns.md` (common patterns and anti-patterns)
- **Security questions (comprehensive)** → `docs/ARCHITECTURE.md#security` (full zero-knowledge architecture)
- **Testing questions** → `reference/testing-patterns.md` (unit tests, mocking, null safety)
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
Note well-implemented patterns (briefly, without elaboration). Keep positive feedback concise.
## Core Principles
## Review Anti-Patterns (DO NOT)
- Be nitpicky about linter-catchable style issues
- Review without understanding context - ask for clarification first
- Focus only on new code - check surrounding context for issues
- Request changes outside the scope of this changeset
## Examples
### Good Review Format
```markdown
## Summary
This PR adds biometric authentication to the login flow, implementing MVVM pattern with proper state management.
## Critical Issues
- `app/login/LoginViewModel.kt:45` - Mutable state exposed; use `StateFlow` instead of `MutableStateFlow`
- `data/auth/BiometricRepository.kt:120` - Missing null safety check on `biometricPrompt` result
## Suggested Improvements
- Consider extracting biometric prompt logic to separate use case class
- Add integration tests for biometric failure scenarios
- `app/login/LoginScreen.kt:89` - Consider using existing `BitwardenButton` component
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
## Action Items
1. Fix mutable state exposure in `LoginViewModel`
2. Add null safety check in `BiometricRepository`
3. Consider adding integration tests for error flows
```
### What to Focus On
**DO focus on:**
- Architecture violations (incorrect patterns)
- Security issues (data handling, encryption)
- Missing tests for critical paths
- Compilation risks (type safety, null safety)
**DON'T focus on:**
- Minor formatting (handled by linters)
- Personal preferences without architectural basis
- Issues outside the changeset scope
- **Priority order**: Security → Correctness → Breaking Changes → Performance → Maintainability
- **Appropriate depth**: Match review rigor to change complexity and risk
- **Specific references**: Always use `file:line_number` format for precise location
- **Actionable feedback**: Say what to do and why, not just what's wrong
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms

View File

@@ -0,0 +1,137 @@
# Bug Fix Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Bug
**1. Understand root cause:**
- What was the broken behavior?
- What caused it?
- How does this fix address the root cause?
**2. Assess scope:**
- How many files changed?
- Is this a targeted fix or broader refactoring?
- Does this affect multiple features?
**3. Check for side effects:**
- Could this break other features?
- Are there edge cases not considered?
### Second Pass: Verify the Fix
**4. Code changes:**
- Does the fix make sense?
- Is it the simplest solution?
- Any unnecessary changes included?
**5. Testing:**
- Is there a regression test?
- Does test verify the bug is fixed?
- Are edge cases covered?
**6. Related code:**
- Same pattern in other places that might have same bug?
- Should other similar code be fixed too?
## What to CHECK
**Root Cause Analysis**
- Does the fix address the root cause or just symptoms?
- Is the explanation in PR/commit clear?
**Regression Testing**
- Is there a new test that would fail without this fix?
- Does test cover the reported bug scenario?
- Are related edge cases tested?
**Side Effects**
- Could this break existing functionality?
- Are there similar code paths that need checking?
- Does this change behavior in unexpected ways?
**Fix Scope**
- Is the fix appropriately scoped (not too broad, not too narrow)?
- Are all instances of the bug fixed?
- Any related bugs discovered during investigation?
## What to SKIP
**Full Architecture Review** - Unless fix reveals architectural problems
**Comprehensive Testing Review** - Focus on regression tests, not entire test suite
**Major Refactoring Suggestions** - Unless directly related to preventing similar bugs
## Red Flags
🚩 **No test for the bug** - How will we prevent regression?
🚩 **Fix doesn't match root cause** - Is this fixing symptoms?
🚩 **Broad changes beyond the bug** - Should this be split into separate PRs?
🚩 **Similar patterns elsewhere** - Should those be fixed too?
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "Can we add a test that would fail without this fix?"
- "I see this pattern in [other file] - does it have the same issue?"
- "Is this fixing the root cause or masking the symptom?"
- "Could this change affect [related feature]?"
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
**Overall Assessment:** APPROVE
See inline comments for suggested improvements.
```
**Inline comment examples:**
```
**data/auth/BiometricRepository.kt:120** - SUGGESTED: Extract null handling
<details>
<summary>Details</summary>
Root cause analysis: BiometricPrompt result was nullable but code assumed non-null, causing crash on cancellation (PM-12345).
Consider extracting null handling pattern:
\```kotlin
private fun handleBiometricResult(result: BiometricPrompt.AuthenticationResult?): AuthResult {
return result?.let { AuthResult.Success(it) } ?: AuthResult.Cancelled
}
\```
This pattern could be reused if we add other biometric auth points.
</details>
```
```
**app/auth/BiometricViewModel.kt:89** - SUGGESTED: Add regression test
<details>
<summary>Details</summary>
Add test for cancellation scenario to prevent regression:
\```kotlin
@Test
fun `when biometric cancelled then returns cancelled state`() = runTest {
coEvery { repository.authenticate() } returns Result.failure(CancelledException())
viewModel.onBiometricAuth()
assertEquals(AuthState.Cancelled, viewModel.state.value)
}
\```
This prevents regression of the bug just fixed.
</details>
```

View File

@@ -0,0 +1,139 @@
# Dependency Update Review Checklist
## Multi-Pass Strategy
### First Pass: Identify and Assess
**1. Identify the change:**
- Which library? Old version → New version?
- Major (X.0.0), Minor (0.X.0), or Patch (0.0.X) version change?
- Single dependency or multiple?
**2. Check compilation safety:**
- Any imports in codebase that might break?
- Any deprecated APIs we're currently using?
- Check if this is a breaking change version
### Second Pass: Deep Analysis
**3. Review release notes** (if available):
- Breaking changes mentioned?
- Security fixes included?
- New features we should know about?
- Deprecations that affect our usage?
**4. Verify consistency:**
- If updating androidx library, are related libraries updated consistently?
- BOM (Bill of Materials) consistency if applicable?
- Test dependencies updated alongside main dependencies?
## What to CHECK
**Compilation Safety**
- Look for API deprecations in our codebase
- Check if import statements still valid
- Major version bumps require extra scrutiny
- Beta/alpha versions need stability assessment
**Security Implications** (if applicable)
- Security-related libraries (crypto, auth, networking)?
- Check for CVEs addressed in release notes
- Review security advisories for this library
**Testing Implications**
- Does this affect test utilities?
- Are there breaking changes in test APIs?
- Do existing tests still cover the same scenarios?
**Changelog Review**
- Read release notes for breaking changes
- Note any behavioral changes
- Check migration guides if major version
## What to SKIP
**Full Architecture Review** - No code changed, patterns unchanged
**Code Style Review** - No code to review
**New Test Requirements** - Unless API changed significantly
**Security Deep-Dive** - Unless crypto/auth/networking library
**Performance Analysis** - Unless release notes mention performance changes
## Red Flags (Escalate to Full Review)
🚩 **Major version bump** (e.g., 1.x → 2.0) - Read `checklists/feature-addition.md`
🚩 **Security/crypto library** - Read `reference/architectural-patterns.md` and `docs/ARCHITECTURE.md#security`
🚩 **Breaking changes in release notes** - Read relevant code sections carefully
🚩 **Multiple dependency updates at once** - Check for interaction risks
🚩 **Beta/Alpha versions** - Assess stability concerns and rollback plan
If any red flags present, escalate to more comprehensive review using appropriate checklist.
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Reviews
### Example 1: Simple Patch Version (No Critical Issues)
```markdown
**Overall Assessment:** APPROVE
See inline comments for all issue details.
```
**Inline comment example:**
```
**libs.versions.toml:45** - SUGGESTED: Beta version in production
<details>
<summary>Details</summary>
androidx.credentials updated from 1.5.0 to 1.6.0-beta03
Monitor for stability issues - beta releases may have unexpected behavior in production.
Changelog: Adds support for additional credential types, internal bug fixes.
</details>
```
### Example 2: Major Version with Breaking Changes (With Critical Issues)
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Breaking API changes in Retrofit 3.0.0 (network/api/BitwardenApiService.kt)
- Breaking API changes in Retrofit 3.0.0 (network/api/VaultApiService.kt)
See inline comments for migration details.
```
**Inline comment example:**
```
**network/api/BitwardenApiService.kt:15** - CRITICAL: Breaking API changes
<details>
<summary>Details and fix</summary>
Retrofit 3.0.0 removes `Call<T>` return type. Migration required:
\```kotlin
// Before
fun getUser(): Call<UserResponse>
// After
suspend fun getUser(): Response<UserResponse>
\```
Update all API service interfaces to use suspend functions, update call sites to use coroutines instead of enqueue/execute, and update tests accordingly.
Consider creating a separate PR for this migration due to scope.
Reference: https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-300
</details>
```

View File

@@ -0,0 +1,211 @@
# Feature Addition Review Checklist
## Multi-Pass Strategy
### First Pass: High-Level Assessment
**1. Understand the feature:**
- Read PR description - what problem does this solve?
- Identify user-facing changes vs internal changes
- Note any security implications (auth, encryption, data handling)
**2. Scan file structure:**
- Which modules affected? (app, data, network, ui, core?)
- Are files organized correctly per module structure?
- Any new public APIs introduced?
**3. Initial risk assessment:**
- Does this touch sensitive data or security-critical paths?
- Does this affect existing features or only add new ones?
- Are there obvious compilation or null safety issues?
### Second Pass: Architecture Deep-Dive
**4. MVVM + UDF Pattern Compliance:**
- ViewModels properly structured?
- State management using StateFlow?
- Business logic in correct layer?
**5. Dependency Injection:**
- Hilt DI used correctly?
- Dependencies injected, not manually instantiated?
- Proper scoping applied?
**6. Module Organization:**
- Code placed in correct modules?
- No circular dependencies introduced?
- Proper separation of concerns?
**7. Error Handling:**
- Using Result types, not exception-based handling?
- Errors propagated correctly through layers?
### Third Pass: Details and Quality
**8. Testing:**
- Unit tests for ViewModels and repositories?
- Test coverage for edge cases and error scenarios?
- Tests verify behavior, not implementation?
**9. Code Quality:**
- Null safety handled properly?
- Public APIs have KDoc documentation?
- Naming follows project conventions?
**10. Security:**
- Sensitive data encrypted properly?
- Authentication/authorization handled correctly?
- Zero-knowledge architecture preserved?
## Architecture Review
Read `reference/architectural-patterns.md` for full patterns and code examples.
**Check these four areas:**
- **MVVM/UDF**: ViewModel exposes `StateFlow` (not `MutableStateFlow`), business logic in Repository, UI is stateless
- **Hilt DI**: `@HiltViewModel` + `@Inject constructor`, inject interfaces not implementations, no manual instantiation
- **Module placement**: UI in `:ui`/`:app`, data in `:data`, network in `:network`, no circular dependencies
- **Error handling**: `Result<T>` / `runCatching` throughout — no thrown exceptions from data layer
## Security Review
Reference: `docs/ARCHITECTURE.md#security`
**Critical Security Checks:**
- **Sensitive data encrypted**: Passwords, keys, tokens use Android Keystore or EncryptedSharedPreferences
- **No plaintext secrets**: No passwords/keys in logs, memory dumps, or SharedPreferences
- **Input validation**: All user-provided data validated and sanitized
- **Authentication tokens**: Securely stored and transmitted
- **Zero-knowledge architecture**: Encryption happens client-side, server never sees plaintext
**Red Flags:**
```kotlin
// ❌ CRITICAL - Plaintext storage
sharedPreferences.edit {
putString("pin", userPin) // Must use EncryptedSharedPreferences
}
// ❌ CRITICAL - Logging sensitive data
Log.d("Auth", "Password: $password") // Never log sensitive data
// ❌ CRITICAL - Weak encryption
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
// ✅ GOOD - Keystore encryption
val encryptedData = keystoreManager.encrypt(sensitiveData)
secureStorage.store(encryptedData)
```
**If security concerns found, classify as CRITICAL using `reference/priority-framework.md`**
## Testing Review
Reference: `reference/testing-patterns.md`
**Required Test Coverage:**
- **ViewModels**: Unit tests for state transitions, actions, error scenarios
- **Repositories**: Unit tests for data transformations, error handling
- **Business logic**: Unit tests for complex algorithms, calculations
- **Edge cases**: Null inputs, empty states, network failures, concurrent operations
**Test Quality:**
```kotlin
// ✅ GOOD - Tests behavior
@Test
fun `when login succeeds then state updates to success`() = runTest {
val viewModel = LoginViewModel(mockRepository)
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
viewModel.onLoginClicked("user", "pass")
viewModel.state.test {
assertEquals(LoginState.Success, awaitItem())
}
}
// ❌ BAD - Tests implementation
@Test
fun `repository is called with correct parameters`() {
// This is testing internal implementation, not behavior
}
```
**Testing Frameworks:**
- JUnit 5 for test structure
- MockK for mocking
- Turbine for Flow testing
- Kotlinx-coroutines-test for coroutine testing
## Code Quality
### Null Safety
- No `!!` (non-null assertion) without clear safety guarantee
- Platform types (from Java) handled with explicit nullability
- Nullable types have proper null checks or use safe operators (`?.`, `?:`)
```kotlin
// ❌ BAD - Unsafe assertion
val result = apiService.getData()!! // Could crash
// ✅ GOOD - Safe handling
val result = apiService.getData() ?: return State.Error("No data")
// ❌ BAD - Platform type unchecked
val intent: Intent = getIntent() // Could be null from Java
intent.getStringExtra("key") // Potential NPE
// ✅ GOOD - Explicit nullability
val intent: Intent? = getIntent()
intent?.getStringExtra("key")
```
### Documentation
- **Public APIs**: Have KDoc comments explaining purpose, parameters, return values
- **Complex algorithms**: Explained in comments
- **Non-obvious behavior**: Documented with rationale
```kotlin
// ✅ GOOD - Documented public API
/**
* Encrypts the given data using AES-256-GCM with a key from Android Keystore.
*
* @param plaintext The data to encrypt
* @return Result containing encrypted data or encryption error
*/
suspend fun encrypt(plaintext: ByteArray): Result<EncryptedData>
```
### Style Compliance
Reference: `docs/STYLE_AND_BEST_PRACTICES.md`
Only flag style issues if:
- Not caught by linters (Detekt, ktlint)
- Have architectural implications
- Significantly impact readability
Skip minor formatting (spaces, line breaks, etc.) - linters handle this.
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Providing Feedback
Use `reference/review-psychology.md` for phrasing guidance.
**Key principles:**
- **Ask questions** for design decisions: "Can we use the existing BitwardenTextField component here?"
- **Be prescriptive** for clear violations: "Change MutableStateFlow to StateFlow (MVVM pattern requirement)"
- **Explain rationale**: "This exposes mutable state, violating unidirectional data flow"
- **Use I-statements**: "It's hard for me to understand this logic without comments"
- **Avoid condescension**: Don't use "just", "simply", "obviously"
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.

View File

@@ -0,0 +1,223 @@
# Infrastructure Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Change
**1. Identify the goal:**
- What problem does this solve?
- Is this optimization, fix, or new capability?
- What's the expected impact?
**2. Assess risk:**
- Does this affect production builds?
- Could this break CI/CD pipelines?
- Impact on developer workflow?
**3. Performance implications:**
- Will builds be faster or slower?
- CI time impact?
- Resource usage changes?
### Second Pass: Verify Implementation
**4. Configuration correctness:**
- Syntax valid?
- References correct?
- Secrets/credentials handled securely?
**5. Impact analysis:**
- What workflows/builds are affected?
- Rollback plan if this breaks?
- Documentation for team?
**6. Testing strategy:**
- How can this be tested before merge?
- Canary/gradual rollout possible?
- Monitoring for issues post-merge?
## What to CHECK
**Configuration Correctness**
- YAML/Groovy syntax valid
- File references correct
- Version numbers/tags valid
- Conditional logic sound
**Security**
- No hardcoded secrets or credentials
- GitHub secrets used properly
- Permissions appropriately scoped
- No sensitive data in logs
**Performance Impact**
- Build time implications understood
- CI queue time impact assessed
- Resource usage reasonable
**Rollback Plan**
- Can this be reverted easily?
- Dependencies on other changes?
- Gradual rollout possible?
**Documentation**
- Changes documented for team?
- README or CONTRIBUTING updated?
- Breaking changes clearly noted?
## What to SKIP
**Bikeshedding Configuration** - Unless clear performance/maintenance benefit
**Over-Optimization** - Unless current system has proven problems
**Suggesting Major Rewrites** - Unless current approach is fundamentally broken
## Red Flags
🚩 **Hardcoded secrets** - Use GitHub secrets or secure storage
🚩 **No rollback plan** - Critical infrastructure should be revertible
🚩 **Untested changes** - CI changes should be validated
🚩 **Breaking changes without notice** - Team needs advance warning
🚩 **Performance regression** - Builds shouldn't get significantly slower
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "What's the rollback plan if this breaks CI?"
- "Can we test this on a feature branch before main?"
- "Will this impact build times? By how much?"
- "Should this be documented in CONTRIBUTING.md?"
## Common Infrastructure Patterns
### GitHub Actions
```yaml
# ✅ GOOD - Secure, clear, tested
name: Build and Test
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30 # Prevent runaway builds
steps:
- uses: actions/checkout@v4
- name: Run tests
env:
API_KEY: ${{ secrets.API_KEY }} # Secure secret usage
run: ./gradlew test
# ❌ BAD - Insecure, unclear
name: Build
on: push # Too broad, runs on all branches
jobs:
build:
runs-on: ubuntu-latest
# No timeout - could run forever
steps:
- run: |
export API_KEY="hardcoded_key_here" # Hardcoded secret!
./gradlew test
```
### Gradle Configuration
```kotlin
// ✅ GOOD - Clear, maintainable
dependencies {
implementation(libs.androidx.core.ktx) // Version catalog
implementation(libs.hilt.android)
testImplementation(libs.junit5)
testImplementation(libs.mockk)
}
// ❌ BAD - Hardcoded versions
dependencies {
implementation("androidx.core:core-ktx:1.12.0") // Hardcoded version
implementation("com.google.dagger:hilt-android:2.48")
}
```
### Build Optimization
```kotlin
// ✅ GOOD - Parallel, cached
tasks.register("checkAll") {
dependsOn("detekt", "ktlintCheck", "testStandardDebug")
group = "verification"
description = "Run all checks in parallel"
// Enable caching for faster builds
outputs.upToDateWhen { false }
}
// ❌ BAD - Sequential, no caching
tasks.register("checkAll") {
doLast {
exec { commandLine("./gradlew", "detekt") }
exec { commandLine("./gradlew", "ktlintCheck") } // Sequential
exec { commandLine("./gradlew", "test") }
}
}
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
## Summary
Optimizes CI build by parallelizing test execution and caching dependencies
Impact: Estimated 40% reduction in CI time (12 min → 7 min per build)
## Critical Issues
None
## Suggested Improvements
**.github/workflows/build.yml:23** - Add timeout for safety
```yaml
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30 # Prevent builds from hanging
steps:
# ...
```
This prevents runaway builds if something goes wrong.
**.github/workflows/build.yml:45** - Consider matrix strategy for module tests
Can we run module tests in parallel using a matrix strategy?
```yaml
strategy:
matrix:
module: [app, data, network, ui]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: ./gradlew :${{ matrix.module }}:test
```
This could further reduce CI time.
**build.gradle.kts:12** - Document caching strategy
Can we add a comment explaining the caching configuration?
Future maintainers will appreciate understanding why these specific cache keys are used.
## Rollback Plan
If CI breaks:
- Revert commit: `git revert [commit-hash]`
- Previous workflow available at: `.github/workflows/build.yml@main^`
- Monitor CI times at: https://github.com/[org]/[repo]/actions
```

View File

@@ -0,0 +1,267 @@
# Refactoring Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Refactoring
**1. Understand the goal:**
- What pattern is being improved?
- Why is this refactoring needed?
- What's the scope of changes?
**2. Assess completeness:**
- Are all instances refactored or just some?
- Are there related areas that should also change?
- Is the migration complete or partial?
**3. Risk assessment:**
- Does this change behavior?
- How many files affected?
- Are tests updated to reflect changes?
### Second Pass: Verify Consistency
**4. Pattern consistency:**
- Is the new pattern applied consistently throughout?
- Are there missed instances of the old pattern?
- Does this match established project patterns?
**5. Migration completeness:**
- Old pattern fully removed or deprecated?
- All usages updated?
- Documentation updated?
**6. Test coverage:**
- Do tests still pass?
- Are tests refactored to match?
- Does behavior remain unchanged?
## What to CHECK
**Pattern Consistency**
- New pattern applied consistently across all touched code
- Follows established project patterns (MVVM, DI, error handling)
- No mix of old and new patterns
**Migration Completeness**
- All instances of old pattern updated?
- Deprecated methods removed or marked @Deprecated?
- Related code also updated (tests, docs)?
**Behavior Preservation**
- Refactoring doesn't change behavior
- Tests still pass
- Edge cases still handled
**Deprecation Strategy** (if applicable)
- Old APIs marked @Deprecated with migration guidance
- Replacement clearly documented
- Timeline for removal specified
## What to SKIP
**Suggesting Additional Refactorings** - Unless directly related to current changes
**Scope Creep** - Don't request refactoring of untouched code
**Perfection** - Better code is better than perfect code
## Red Flags
🚩 **Incomplete migration** - Mix of old and new patterns
🚩 **Behavior changes** - Refactoring shouldn't change behavior
🚩 **Broken tests** - Tests should be updated to match refactoring
🚩 **Undocumented pattern** - New pattern should be clear to team
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "I see the old pattern still used in [file:line] - should that be updated too?"
- "Can we add @Deprecated to the old method with migration guidance?"
- "How do we ensure this behavior remains the same?"
- "Should this pattern be documented in ARCHITECTURE.md?"
## Common Refactoring Patterns
### Extract Interface/Repository
```kotlin
// ✅ GOOD - Complete migration
interface FeatureRepository {
suspend fun getData(): Result<Data>
}
class FeatureRepositoryImpl @Inject constructor(
private val apiService: FeatureApiService
) : FeatureRepository {
override suspend fun getData(): Result<Data> = runCatching {
apiService.fetchData()
}
}
// All usages updated to inject interface
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository // Interface
) : ViewModel()
// ❌ BAD - Incomplete migration
// Some files still inject FeatureRepositoryImpl directly
```
### Modernize Error Handling
```kotlin
// ✅ GOOD - Complete migration
// Old exception-based removed
suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
// All call sites updated
repository.fetchData().fold(
onSuccess = { /* handle */ },
onFailure = { /* handle */ }
)
// ❌ BAD - Mixed patterns
// Some functions use Result, others still throw exceptions
```
### Extract Reusable Component
```kotlin
// ✅ GOOD - Complete extraction
// Component moved to :ui module
@Composable
fun BitwardenButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
)
// All usages updated to use new component
// Old inline button implementations removed
// ❌ BAD - Incomplete extraction
// Some screens use new component, others still have inline implementation
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Reviews
### Example 1: Refactoring with Incomplete Migration
**Context**: Refactoring authentication to Repository pattern, but one ViewModel still uses old pattern
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Incomplete migration (app/vault/VaultViewModel.kt:89)
See inline comments for details.
```
**Inline Comment 1** (on `app/vault/VaultViewModel.kt:89`):
```markdown
**IMPORTANT**: Incomplete migration
<details>
<summary>Details and fix</summary>
This ViewModel still injects AuthManager directly. Should it use AuthRepository like the other 11 ViewModels?
\```kotlin
// Current (old pattern)
class VaultViewModel @Inject constructor(
private val authManager: AuthManager
)
// Should be (new pattern)
class VaultViewModel @Inject constructor(
private val authRepository: AuthRepository
)
\```
This is the only ViewModel still using the old pattern.
</details>
```
**Inline Comment 2** (on `data/auth/AuthManager.kt:1`):
```markdown
**SUGGESTED**: Add deprecation notice
<details>
<summary>Details</summary>
Can we add @Deprecated to AuthManager to guide future development?
\```kotlin
@Deprecated(
message = "Use AuthRepository interface instead",
replaceWith = ReplaceWith("AuthRepository"),
level = DeprecationLevel.WARNING
)
class AuthManager @Inject constructor(...)
\```
This helps prevent new code from using the old pattern.
</details>
```
---
### Example 2: Clean Refactoring (No Issues)
**Context**: Refactoring with complete migration, all patterns followed correctly, tests passing
**Review Comment:**
```markdown
**Overall Assessment:** APPROVE
Clean refactoring moving ExitManager to :ui module. Follows established patterns, eliminates duplication, tests updated correctly.
```
**Token count:** ~30 tokens (vs ~800 for verbose format)
**Why this works:**
- 3 lines total
- Clear approval decision
- Briefly notes what was done
- No elaborate sections, checkmarks, or excessive praise
- Author gets immediate green light to merge
**What NOT to do for clean refactorings:**
```markdown
❌ DO NOT create these sections:
## Summary
This PR successfully refactors ExitManager into shared code...
## Key Strengths
- ✅ Follows established module organization patterns
- ✅ Removes code duplication between apps
- ✅ Improves test coverage
- ✅ Maintains consistent behavior
[...20 more checkmarks...]
## Code Quality & Architecture
**Architectural Compliance:** ✅
- Correctly places manager in :ui module
- Follows established pattern for UI-layer managers
[...detailed analysis...]
## Changes
- ✅ Moved ExitManager interface from app → ui module
- ✅ Moved ExitManagerImpl from app → ui module
[...listing every file...]
```
This is excessive. **For clean PRs: 2-3 lines maximum.**

View File

@@ -0,0 +1,206 @@
# UI Refinement Review Checklist
## Multi-Pass Strategy
### First Pass: Visual Changes
**1. Understand the changes:**
- What visual/UX problem is being solved?
- Are there designs or screenshots to reference?
- Is this a bug fix or enhancement?
**2. Component usage:**
- Using existing components from `:ui` module?
- Any new custom components created?
- Could existing components be reused?
### Second Pass: Implementation Review
**3. Compose best practices:**
- Composables properly structured?
- State hoisted correctly?
- Preview composables included?
**4. Accessibility:**
- Content descriptions for images/icons?
- Semantic properties for screen readers?
- Touch targets meet minimum size (48dp)?
**5. Design consistency:**
- Using theme colors, spacing, typography?
- Consistent with other screens?
- Responsive to different screen sizes?
## What to CHECK
**Compose Best Practices**
- Composables are stateless where possible
- State hoisting follows patterns
- Side effects (LaunchedEffect, DisposableEffect) used correctly
- Preview composables provided for development
**Component Reuse**
- Using existing BitwardenButton, BitwardenTextField, etc.?
- Could custom UI be replaced with existing components?
- New reusable components placed in `:ui` module?
**Accessibility**
- `contentDescription` for icons and images
- `semantics` for custom interactions
- Sufficient contrast ratios
- Touch targets ≥ 48dp minimum
**Design Consistency**
- Using `BitwardenTheme` colors (not hardcoded)
- Using `BitwardenTheme` spacing (16.dp, 8.dp, etc.)
- Using `BitwardenTheme` typography styles
- Consistent with existing screen patterns
**Responsive Design**
- Handles different screen sizes?
- Scrollable content where appropriate?
- Landscape orientation considered?
## What to SKIP
**Deep Architecture Review** - Unless ViewModel changes are substantial
**Business Logic Review** - Focus is on presentation, not logic
**Security Review** - Unless UI exposes sensitive data improperly
## Red Flags
🚩 **Duplicating existing components** - Should reuse from `:ui` module
🚩 **Hardcoded colors/dimensions** - Should use theme
🚩 **Missing accessibility properties** - Critical for screen readers
🚩 **State management in UI** - Should be hoisted to ViewModel
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "Can we use BitwardenButton here instead of this custom button?"
- "Should this color come from BitwardenTheme instead of being hardcoded?"
- "How will this look on a small screen?"
- "Is there a contentDescription for this icon?"
## Common Patterns
### Composable Structure
```kotlin
// ✅ GOOD - Stateless, hoisted state
@Composable
fun FeatureScreen(
state: FeatureState,
onActionClick: () -> Unit,
modifier: Modifier = Modifier
) {
// UI rendering only
}
// ❌ BAD - Business state in composable
@Composable
fun FeatureScreen() {
var userData by remember { mutableStateOf<User?>(null) } // Business state should be in ViewModel
var isLoading by remember { mutableStateOf(false) } // App state should be in ViewModel
// ...
}
// ✅ OK - UI-local state in composable
@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
var username by remember { mutableStateOf("") } // UI-local input state is fine
var password by remember { mutableStateOf("") }
// Hoist only as high as needed
}
```
### Theme Usage
```kotlin
// ✅ GOOD - Using theme
Text(
text = "Title",
style = BitwardenTheme.typography.titleLarge,
color = BitwardenTheme.colorScheme.primary
)
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
Spacer(modifier = Modifier.height(16.dp))
// ❌ BAD - Hardcoded
Text(
text = "Title",
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Should use theme
color = Color(0xFF0000FF) // Should use theme color
)
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
```
### Accessibility
```kotlin
// ✅ GOOD - Interactive element with description
Icon(
painter = painterResource(R.drawable.ic_password),
contentDescription = "Password visibility toggle",
modifier = Modifier.clickable { onToggle() }
)
// ✅ GOOD - Decorative icon with explicit null
Icon(
painter = painterResource(R.drawable.ic_check),
contentDescription = null, // Decorative icon next to descriptive text
tint = BitwardenTheme.colorScheme.success
)
// ❌ BAD - Interactive element missing description
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null, // Interactive elements need descriptions
modifier = Modifier.clickable { onDelete() }
)
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
## Summary
Updates login screen layout for improved visual hierarchy and touch targets
## Critical Issues
None
## Suggested Improvements
**app/auth/LoginScreen.kt:67** - Can we use BitwardenTextField?
This custom text field looks very similar to `ui/components/BitwardenTextField.kt:89`.
Would using the existing component maintain consistency?
**app/auth/LoginScreen.kt:123** - Add contentDescription
```kotlin
Icon(
painter = painterResource(R.drawable.ic_visibility),
contentDescription = "Show password", // Add for accessibility
modifier = Modifier.clickable { onToggleVisibility() }
)
```
**app/auth/LoginScreen.kt:145** - Use design system spacing
```kotlin
// Current
Spacer(modifier = Modifier.height(17.dp))
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
Spacer(modifier = Modifier.height(16.dp))
```
```

View File

@@ -0,0 +1,459 @@
# Review Output Examples
Well-structured code reviews demonstrating appropriate depth, tone, and formatting for different change types.
## Table of Contents
**Format Reference:**
- [Quick Format Reference](#quick-format-reference)
- [Inline Comment Format](#inline-comment-format-required)
- [Summary Comment Format](#summary-comment-format)
**Examples:**
- [Example 1: Clean PR (No Issues)](#example-1-clean-pr-no-issues)
- [Example 2: Dependency Update with Breaking Changes](#example-2-dependency-update-with-breaking-changes)
- [Example 3: Feature Addition with Critical Issues](#example-3-feature-addition-with-critical-issues)
**Anti-Patterns:**
- [❌ Anti-Patterns to Avoid](#-anti-patterns-to-avoid)
- [Problem: Verbose Summary with Multiple Sections](#problem-verbose-summary-with-multiple-sections)
- [Problem: Praise-Only Inline Comments](#problem-praise-only-inline-comments)
- [Problem: Missing `<details>` Tags](#problem-missing-details-tags)
**Summary:**
- [Summary](#summary)
---
## Quick Format Reference
### Inline Comment Format (REQUIRED)
**MUST use `<details>` tags.** Only severity + description visible; all other content collapsed.
```
[emoji] **[SEVERITY]**: [One-line issue description]
<details>
<summary>Details and fix</summary>
[Code example or specific fix]
[Rationale explaining why]
Reference: [docs link if applicable]
</details>
```
**Severity Levels:**
-**CRITICAL** - Blocking, must fix (security, crashes, architecture violations)
- ⚠️ **IMPORTANT** - Should fix (missing tests, quality issues)
- ♻️ **DEBT** - Technical debt (duplication, convention violations, future rework needed)
- 🎨 **SUGGESTED** - Nice to have (refactoring, improvements)
-**QUESTION** - Seeking clarification (requirements, design decisions)
### Summary Comment Format
Uses the agent's `posting-review-summary` skill format. Surface ❌ CRITICAL issues at the top level for immediate visibility, wrap the full findings list in `<details>` for scannability.
```
**Overall Assessment:** APPROVE / REQUEST CHANGES
[1-2 neutral sentences describing what was reviewed]
**Critical Issues** (if any):
- ❌ [One-line summary with file:line]
<details>
<summary>All findings</summary>
- ❌ **CRITICAL**: [description] (`file:line`)
- ⚠️ **IMPORTANT**: [description] (`file:line`)
- ♻️ **DEBT**: [description] (`file:line`)
- 🎨 **SUGGESTED**: [description] (`file:line`)
- ❓ **QUESTION**: [description] (`file:line`)
</details>
```
For clean PRs with no findings, omit both sections entirely — verdict + 1-2 sentences is sufficient.
**GitHub pitfall**: Never use `#` followed by a number in comment text (e.g., `#42`, `#PR123`). GitHub autolinks these to issues/PRs. Use `Finding 1:` or `item 42` instead.
---
## Example 1: Clean PR (No Issues)
**Context**: Moving shared code to common module, complete migration, all patterns followed
**Review Comment:**
```markdown
**Overall Assessment:** APPROVE
Clean refactoring that moves ExitManager to :ui module, eliminating duplication between apps.
```
**Why this works:**
- Immediate approval visible (2-3 lines)
- One sentence acknowledging the work
- No unnecessary sections or elaborate praise
- Author gets quick feedback and can proceed
---
## Example 2: Dependency Update with Breaking Changes
**Context**: Major version update requiring code migration
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- API migration required for Retrofit 3.0 breaking changes (network/api/BitwardenApiService.kt:34)
See inline comments for migration details.
```
**Inline Comment 1** (on `network/api/BitwardenApiService.kt:34`):
```markdown
**CRITICAL**: API migration required for Retrofit 3.0
<details>
<summary>Details and fix</summary>
Retrofit 3.0 removes the `Call<T>` return type. All 12 API methods in this file need migration:
```kotlin
// Current (deprecated in Retrofit 3.0)
@GET("api/accounts/profile")
fun getProfile(): Call<ProfileResponse>
// Must migrate to
@GET("api/accounts/profile")
suspend fun getProfile(): Response<ProfileResponse>
```
Breaking API change affects:
- 12 methods in BitwardenApiService
- 8 methods in VaultApiService
- All call sites using enqueue/execute
- Test utilities
Consider creating separate PR for this migration given the scope.
Reference: [Retrofit 3.0 migration guide](https://square.github.io/retrofit/changelogs/changelog-3.x/)
</details>
```
**Key Features:**
- Minimal summary (2-3 lines)
- Full details in collapsed inline comment
- Specific file:line references
- Code examples in <details>
- Migration guidance and scope assessment
---
## Example 3: Feature Addition with Critical Issues
**Context**: Implements PIN unlock for vault access
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Exposes mutable state violating MVVM (UnlockViewModel.kt:78)
- PIN stored without encryption - SECURITY ISSUE (UnlockRepository.kt:145)
See inline comments for all issues and suggestions.
```
**Inline Comment 1** (on `app/vault/unlock/UnlockViewModel.kt:78`):
```markdown
**CRITICAL**: Exposes mutable state
<details>
<summary>Details and fix</summary>
Change `MutableStateFlow<State>` to `StateFlow<State>`:
```kotlin
// Current (problematic)
val unlockState: MutableStateFlow<UnlockState>
// Should be
private val _unlockState = MutableStateFlow<UnlockState>()
val unlockState: StateFlow<UnlockState> = _unlockState.asStateFlow()
```
Exposing MutableStateFlow allows external mutation, violating MVVM unidirectional data flow.
Reference: docs/ARCHITECTURE.md#mvvm-pattern
</details>
```
**Inline Comment 2** (on `data/vault/UnlockRepository.kt:145`):
```markdown
❌ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
<details>
<summary>Details and fix</summary>
Storing PIN in plaintext SharedPreferences exposes it to backup systems and rooted devices.
```kotlin
// Current (CRITICAL SECURITY ISSUE)
sharedPreferences.edit {
putString(KEY_PIN, pin)
}
// Must use Android Keystore encryption
suspend fun storePin(pin: String): Result<Unit> = runCatching {
val encrypted = keystoreManager.encrypt(pin.toByteArray())
encryptedPrefs.putBytes(KEY_PIN, encrypted)
}
```
Use Android Keystore encryption or EncryptedSharedPreferences per security architecture.
Reference: docs/ARCHITECTURE.md#security
</details>
```
**Inline Comment 3** (on `app/vault/unlock/UnlockViewModel.kt:92`):
```markdown
⚠️ **IMPORTANT**: Missing error handling test
<details>
<summary>Details and fix</summary>
Add test to prevent regression if error handling changes:
```kotlin
@Test
fun `when incorrect PIN entered then returns error state`() = runTest {
val viewModel = UnlockViewModel(mockRepository)
coEvery { mockRepository.validatePin("1234") }
returns Result.failure(InvalidPinException())
viewModel.onPinEntered("1234")
assertEquals(UnlockState.Error("Invalid PIN"), viewModel.state.value)
}
```
Ensures error flow remains robust across refactorings.
</details>
```
**Inline Comment 4** (on `app/vault/unlock/UnlockViewModel.kt:105`):
```markdown
🎨 **SUGGESTED**: Consider rate limiting for PIN attempts
<details>
<summary>Details and fix</summary>
Currently allows unlimited attempts, which could enable brute force attacks.
```kotlin
private var attemptCount = 0
private var lockoutUntil: Instant? = null
fun onPinEntered(pin: String) {
if (isLockedOut()) {
_state.value = UnlockState.LockedOut(lockoutUntil!!)
return
}
// ... validate PIN ...
if (invalid) {
attemptCount++
if (attemptCount >= MAX_ATTEMPTS) {
lockoutUntil = clock.millis() + 15.minutes
}
}
}
```
Would add security layer against brute force. Consider discussing threat model with security team.
</details>
```
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
```markdown
❓ **QUESTION**: Can we use BitwardenTextField?
<details>
<summary>Details</summary>
This custom PIN input field looks similar to `ui/components/BitwardenTextField.kt:67`.
Would using the existing component maintain consistency and reduce custom UI code?
</details>
```
**Key Features:**
- Minimal summary (3-4 lines) with critical issues only
- Each issue gets separate inline comment with `<details>` tag
- Multiple severity levels demonstrated (CRITICAL, IMPORTANT, SUGGESTED, QUESTION)
- Mix of prescriptive fixes and collaborative questions
- Code examples collapsed in <details>
- No "Good Practices" or "Action Items" sections
---
## ❌ Anti-Patterns to Avoid
### Problem: Verbose Summary with Multiple Sections
**What NOT to do:**
```markdown
### Review Complete ✅
## Summary
[Lengthy description of what the PR does]
### Strengths 👍
1. **Excellent documentation** - KDoc comments are comprehensive
2. **Proper fail-closed design** - Security defaults to rejection
3. **Defense in depth** - Multiple validation layers
[7 total items with elaboration]
### Critical Issues ⚠️
- Missing test coverage for security-critical code (with full details)
- [More issues with full explanations]
### Recommendations 🎨
- [Multiple recommendations]
### Test Coverage Status 📊
- [Analysis]
### Architecture Compliance ✅
- [Analysis]
## Recommendation
**Conditional approval** with follow-up...
```
**Why this is wrong:**
- 800+ tokens for a summary comment
- Multiple sections (Strengths, Recommendations, Test Coverage, Architecture)
- Elaborates on positive aspects ("Excellent documentation...")
- Duplicates critical issues (summary has details + inline comments have same details)
- Creates visual clutter in PR conversation
**Correct approach:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Missing test coverage for security-critical code (PasswordManagerSignatureVerifierImpl.kt:47)
See inline comments for details.
```
**Key differences:**
- 3-5 lines vs 800+ tokens
- Verdict + critical issues only
- All details belong in inline comments
- No positive commentary sections
- Scales with PR complexity, not analysis thoroughness
### Problem: Praise-Only Inline Comments
**What NOT to do:**
Creating inline comment on `AuthenticatorBridgeManagerImpl.kt:73`:
```markdown
👍 **Excellent integration of signature verification**
The signature verification is properly integrated into the connection flow:
- Checked during initialization (line 73)
- Checked before binding (line 134)
- Ensures only validated apps can connect
This is exactly the right approach for fail-safe security.
```
**Why this is wrong:**
- Entire comment is positive feedback with no actionable issue
- Takes up space in PR conversation
- Distracts from actual issues
- Violates "focus on actionable feedback" principle
**Correct approach:**
- Do not create this comment at all
- Reserve inline comments exclusively for issues requiring attention
### Problem: Missing `<details>` Tags
**What NOT to do:**
```markdown
**CRITICAL**: Missing test coverage for security-critical code
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
**Problems:**
1. No validation that certificate hashes match actual Bitwarden certificates
2. No verification of fail-closed behavior on edge cases
3. No tests for multiple signer rejection logic
4. Certificate hash typos would go undetected until production
**Recommendation:**
Replace `@OmitFromCoverage` with proper unit tests.
Example test structure:
[long code block]
Security-critical code should have the highest test coverage, not be omitted.
```
**Why this is wrong:**
- All content visible immediately (code examples, problems list, rationale)
- Creates visual clutter in PR conversation
- Makes it hard to scan multiple issues quickly
**Correct approach:**
```markdown
**CRITICAL**: Missing test coverage for security-critical code
<details>
<summary>Details and fix</summary>
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
**Problems:**
1. No validation that certificate hashes match actual Bitwarden certificates
2. No verification of fail-closed behavior on edge cases
3. No tests for multiple signer rejection logic
4. Certificate hash typos would go undetected until production
**Recommendation:**
Replace `@OmitFromCoverage` with proper unit tests.
Example test structure:
[code block]
Security-critical code should have the highest test coverage, not be omitted.
</details>
```
**Key difference:** Only severity + one-line description visible. All details collapsed.
---
## Summary
**Always use:**
- Minimal summary (verdict + critical issues)
- Separate inline comments with `<details>` tags
- Hybrid emoji + text severity prefixes
- Focus exclusively on actionable feedback
**Never use:**
- Multiple summary sections (Strengths, Recommendations, etc.)
- Praise-only inline comments
- Duplication between summary and inline comments
- Verbose analysis in summary (belongs in inline comments)

View File

@@ -0,0 +1,351 @@
# Architectural Patterns Quick Reference
Quick reference for Bitwarden Android architectural patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## Table of Contents
**Core Patterns:**
- [MVVM + UDF Pattern](#mvvm--udf-pattern)
- [ViewModel Structure](#viewmodel-structure)
- [UI Layer (Compose)](#ui-layer-compose)
- [Hilt Dependency Injection](#hilt-dependency-injection)
- [ViewModels](#viewmodels)
- [Repositories and Managers](#repositories-and-managers)
- [Clock/Time Handling](#clocktime-handling)
- [Module Organization](#module-organization)
- [Error Handling](#error-handling)
- [Use Result Types, Not Exceptions](#use-result-types-not-exceptions)
- [Quick Checklist](#quick-checklist)
---
## MVVM + UDF Pattern
### ViewModel Structure
**✅ GOOD - Proper state encapsulation**:
```kotlin
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository
) : ViewModel() {
// Private mutable state
private val _state = MutableStateFlow<FeatureState>(FeatureState.Initial)
// Public immutable state
val state: StateFlow<FeatureState> = _state.asStateFlow()
// Actions as functions, state updated via internal action
fun onActionClicked() {
viewModelScope.launch {
val result = repository.performAction()
sendAction(FeatureAction.Internal.ActionComplete(result))
}
}
// The ViewModel has a handler that processes the internal action
private fun handleInternalAction(action: FeatureAction.Internal) {
when (action) {
is FeatureAction.Internal.ActionComplete -> {
// The action handler evaluates the result and updates state
action.result.fold(
onSuccess = { _state.value = State.Success },
onFailure = { _state.value = State.Error(it) }
)
}
}
}
}
```
**❌ BAD - Common violations**:
```kotlin
class FeatureViewModel : ViewModel() {
// ❌ Exposes mutable state
val state: MutableStateFlow<FeatureState>
// ❌ Business logic in ViewModel
fun onSubmit() {
val encrypted = encryptionManager.encrypt(data) // Should be in Repository
_state.value = FeatureState.Success
}
// ❌ Direct Android framework dependency
fun onCreate(context: Context) { // ViewModels shouldn't depend on Context
// ...
}
}
```
**Key Rules**:
- Expose `StateFlow<T>`, never `MutableStateFlow<T>`
- Delegate business logic to Repository/Manager
- No direct Android framework dependencies (except ViewModel, SavedStateHandle)
- Use `viewModelScope` for coroutines
Reference: `docs/ARCHITECTURE.md#mvvm-pattern`
---
### UI Layer (Compose)
**✅ GOOD - Stateless, observes only**:
```kotlin
@Composable
fun FeatureScreen(
state: FeatureState,
onActionClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
when (state) {
is FeatureState.Loading -> LoadingIndicator()
is FeatureState.Success -> SuccessContent(state.data)
is FeatureState.Error -> ErrorMessage(state.error)
}
BitwardenButton(
text = "Action",
onClick = onActionClick // Sends event to ViewModel
)
}
}
```
**❌ BAD - Stateful, modifies state**:
```kotlin
@Composable
fun FeatureScreen(viewModel: FeatureViewModel) {
var localState by remember { mutableStateOf(...) } // ❌ State in UI
Button(onClick = {
viewModel._state.value = FeatureState.Loading // ❌ Directly modifying ViewModel state
})
}
```
**Key Rules**:
- Compose screens observe state, never modify
- User actions passed as events/callbacks to ViewModel
- No business logic in UI layer
- Use existing components from `:ui` module
---
## Hilt Dependency Injection
### ViewModels
**✅ GOOD - Interface injection**:
```kotlin
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository, // Interface, not implementation
private val authManager: AuthManager,
savedStateHandle: SavedStateHandle
) : ViewModel()
```
**❌ BAD - Common violations**:
```kotlin
// ❌ No @HiltViewModel annotation
class FeatureViewModel @Inject constructor(...)
// ❌ Injecting implementation instead of interface
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepositoryImpl // Should inject interface
)
// ❌ Manual instantiation
class FeatureViewModel : ViewModel() {
private val repository = FeatureRepositoryImpl() // Should use @Inject
}
```
**Key Rules**:
- Annotate with `@HiltViewModel`
- Use `@Inject constructor`
- Inject interfaces, not implementations
- Use `SavedStateHandle` for process death survival
Reference: `docs/ARCHITECTURE.md#dependency-injection`
---
### Repositories and Managers
**✅ GOOD - Implementation with @Inject**:
```kotlin
interface FeatureRepository {
suspend fun fetchData(): Result<Data>
}
class FeatureRepositoryImpl @Inject constructor(
private val apiService: FeatureApiService,
private val database: FeatureDao
) : FeatureRepository {
override suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
}
```
**Module provides interface**:
```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindFeatureRepository(
impl: FeatureRepositoryImpl
): FeatureRepository
}
```
**Key Rules**:
- Define interface for abstraction
- Implementation uses `@Inject constructor`
- Module binds implementation to interface
- Appropriate scoping (`@Singleton`, `@ViewModelScoped`)
---
### Clock/Time Handling
Time-dependent code must use injected `Clock` rather than direct `Instant.now()` or `DateTime.now()` calls. This follows the same DI principle as other dependencies.
**✅ GOOD - Injected Clock**:
```kotlin
// ViewModel with Clock injection
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
fun save() {
val timestamp = clock.instant()
}
}
// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
```
**❌ BAD - Static/direct calls**:
```kotlin
// Hidden dependency, non-testable
val timestamp = Instant.now()
val dateTime = DateTime.now()
```
**Key Rules**:
- Inject `Clock` via Hilt constructor (like other dependencies)
- Pass `Clock` as parameter to extension functions
- `Clock` is provided via `CoreModule` as singleton
- Enables deterministic testing with `Clock.fixed(...)`
Reference: `docs/STYLE_AND_BEST_PRACTICES.md#best-practices--time-and-clock-handling`
---
## Module Organization
```
android/
├── core/ # Shared utilities (cryptography, analytics, logging)
├── data/ # Repositories, database, domain models
├── network/ # API clients, network utilities
├── ui/ # Reusable Compose components, theme
├── app/ # Application, feature screens, ViewModels
└── authenticator/ # Authenticator app (separate from password manager)
```
**Correct Placement**:
- UI screens and ViewModels → `:app`
- Reusable Compose components → `:ui`
- Data models and Repositories → `:data`
- API services → `:network`
- Cryptography, logging → `:core`
**Check for**:
- No circular dependencies
- Correct module placement
- Proper visibility (internal vs public)
Reference: `docs/ARCHITECTURE.md#module-structure`
---
## Error Handling
### Use Result Types, Not Exceptions
**✅ GOOD - Result-based**:
```kotlin
// Repository
suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
// ViewModel
fun onFetch() {
viewModelScope.launch {
val result = repository.fetchData()
sendAction(FeatureAction.Internal.FetchComplete(result))
}
}
```
**❌ BAD - Exception-based in business logic**:
```kotlin
// ❌ Don't throw in business logic
suspend fun fetchData(): Data {
try {
return apiService.getData()
} catch (e: Exception) {
throw FeatureException(e) // Don't throw in repositories
}
}
// ❌ Try-catch in ViewModel
fun onFetch() {
viewModelScope.launch {
try {
val data = repository.fetchData()
sendAction(FeatureAction.Internal.FetchComplete(data))
} catch (e: Exception) {
sendAction(FeatureAction.Internal.FetchComplete(e))
}
}
}
```
**Key Rules**:
- Use `Result<T>` return types in repositories
- Use `runCatching { }` to wrap API calls
- Handle results with `.fold()` in ViewModels
- Don't throw exceptions in business logic
Reference: `docs/ARCHITECTURE.md#error-handling`
---
## Quick Checklist
### Architecture
- [ ] ViewModels expose StateFlow, not MutableStateFlow?
- [ ] Business logic in Repository, not ViewModel?
- [ ] Using Hilt DI (@HiltViewModel, @Inject constructor)?
- [ ] Injecting interfaces, not implementations?
- [ ] Time-dependent code uses injected `Clock` (not `Instant.now()`)?
- [ ] Correct module placement?
### Error Handling
- [ ] Using Result types, not exceptions in business logic?
- [ ] Errors handled with .fold() in ViewModels?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,430 @@
# Finding Priority Framework
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
## Table of Contents
**Severity Categories:**
- [❌ CRITICAL (Blocker - Must Fix Before Merge)](#critical-blocker---must-fix-before-merge)
- [⚠️ IMPORTANT (Should Fix)](#important-should-fix)
- [♻️ DEBT (Technical Debt)](#debt-technical-debt)
- [🎨 SUGGESTED (Nice to Have)](#suggested-nice-to-have)
- [❓ QUESTION (Seeking Clarification)](#question-seeking-clarification)
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
**Guidelines:**
- [Classification Guidelines](#classification-guidelines)
- [When Something is Between Categories](#when-something-is-between-categories)
- [Context Matters](#context-matters)
- [Examples by Change Type](#examples-by-change-type)
- [Special Cases](#special-cases)
- [Summary](#summary)
---
## ❌ **CRITICAL** (Blocker - Must Fix Before Merge)
These issues **must** be addressed before the PR can be merged. They pose immediate risks to security, stability, or architecture integrity.
### Security
- Data leaks or plaintext sensitive data (passwords, keys, tokens)
- Weak encryption or insecure key storage
- Missing authentication or authorization checks
- Input injection vulnerabilities (SQL, XSS, command injection)
- Sensitive data in logs or error messages
**Example**:
```
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
Reference: docs/ARCHITECTURE.md#security
```
### Stability
- Compilation errors or warnings
- Null pointer exceptions in production paths
- Resource leaks (file handles, network connections, memory)
- Crashes or unhandled exceptions in critical paths
- Thread safety violations
**Example**:
```
**app/auth/BiometricRepository.kt:120** - CRITICAL: Missing null safety check
biometricPrompt result can be null. Add explicit null check to prevent crash.
```
### Architecture
- Mutable state exposure in ViewModels (violates MVVM)
- Exception-based error handling in business logic (should use Result)
- Circular dependencies between modules
- Violation of zero-knowledge principles
- Direct dependency instantiation (should use DI)
**Example**:
```
**app/login/LoginViewModel.kt:45** - CRITICAL: Exposes mutable state
Change MutableStateFlow to StateFlow in public API to prevent external state mutation.
This violates MVVM encapsulation pattern.
```
---
## ⚠️ **IMPORTANT** (Should Fix)
These issues should be addressed but don't block merge if there's a compelling reason. They improve code quality, maintainability, or robustness.
### Testing
- Missing tests for critical paths (authentication, encryption, data sync)
- Missing tests for new public APIs
- Tests that don't verify actual behavior (test implementation, not behavior)
- Missing test coverage for error scenarios
**Example**:
```
**data/auth/BiometricRepository.kt** - IMPORTANT: Missing test for cancellation
Add test for user cancellation scenario to prevent regression.
```
### Architecture
- Inconsistent patterns within PR (mixing error handling approaches)
- Poor separation of concerns
- Tight coupling between components
- Not following established project patterns
**Example**:
```
**app/vault/VaultViewModel.kt:89** - IMPORTANT: Business logic in ViewModel
Encryption logic should be in Repository, not ViewModel.
Reference: docs/ARCHITECTURE.md#mvvm-pattern
```
### Documentation
- Undocumented public APIs (missing KDoc)
- Missing documentation for complex algorithms
- Unclear naming or confusing interfaces
**Example**:
```
**core/crypto/EncryptionManager.kt:34** - IMPORTANT: Missing KDoc
Public encryption method should document parameters, return value, and exceptions.
```
### Performance
- Inefficient algorithms in hot paths (with evidence from profiling)
- Blocking main thread with I/O operations
- Memory inefficient data structures (with evidence)
**Example**:
```
**app/vault/VaultListViewModel.kt:78** - IMPORTANT: N+1 query pattern
Fetching items one-by-one in loop. Consider batch fetch to reduce database queries.
```
---
## ♻️ **DEBT** (Technical Debt)
Code that duplicates existing patterns, violates established conventions, or will require rework within 6 months. Introduces technical debt that should be tracked for future cleanup.
### Duplication
- Copy-pasted code blocks across files
- Repeated validation or business logic
- Multiple implementations of same pattern
- Data transformation duplicated in multiple places
**Example**:
```
**app/vault/VaultListViewModel.kt:156** - DEBT: Duplicates encryption logic
Same encryption pattern exists in VaultRepository.kt:234 and SyncManager.kt:89.
Extract to shared EncryptionUtil to reduce maintenance burden.
```
### Convention Violations
- Inconsistent error handling approaches within same module
- Mixing architectural patterns (MVVM + MVC)
- Not following established DI patterns
- Deviating from project code style significantly
**Example**:
```
**data/auth/AuthRepository.kt:78** - DEBT: Exception-based error handling
Project standard is Result<T> for error handling. This uses try-catch with throws.
Creates inconsistency and makes testing harder.
Reference: docs/ARCHITECTURE.md#error-handling
```
### Future Rework Required
- Hardcoded values that should be configurable
- Temporary workarounds without TODO/FIXME
- Code that will need changes when planned features arrive
- Tight coupling that prevents future extensibility
**Example**:
```
**app/settings/SettingsViewModel.kt:45** - DEBT: Hardcoded feature flags
Feature flags should come from remote config for A/B testing.
Will require rework when experimentation framework launches.
```
---
## 🎨 **SUGGESTED** (Nice to Have)
Improvements with measurable value only. A finding qualifies as SUGGESTED if it provides: security gain, cyclomatic complexity reduction, bug class prevention, or elimination of an O(n²) pattern. Subjective style preferences, vague simplifications, and naming nitpicks do not qualify — leave those out entirely or raise in conversation.
### Code Quality
- Extractable duplicated logic that reduces measurable complexity or improves testability
- Patterns that would prevent a recurring bug class in this module
- Architecture improvements that eliminate tight coupling with measurable impact
**Example**:
```
**app/vault/VaultScreen.kt:145** - SUGGESTED: Consider extracting helper function
This 20-line block appears in 3 places. Consider extracting to reduce duplication.
```
### Testing
- Additional test coverage for edge cases (beyond critical paths)
- More comprehensive integration tests
- Performance tests for non-critical paths
**Example**:
```
**app/login/LoginViewModelTest.kt** - SUGGESTED: Add test for concurrent login attempts
Not critical, but would increase confidence in edge case handling.
```
### Refactoring
- Extracting reusable patterns
- Modernizing old patterns (if touching related code)
- Improving testability
**Example**:
```
**data/vault/VaultRepository.kt:200** - SUGGESTED: Consider extracting validation logic
Could be extracted to separate validator class for reusability and testing.
```
---
## ❓ **QUESTION** (Seeking Clarification)
Questions about requirements, unclear intent, or potential conflicts that require human knowledge to answer. Open inquiries that cannot be resolved through code inspection alone.
### Requirements Clarification
- Ambiguous acceptance criteria
- Multiple valid implementation approaches
- Unclear business rules or edge case handling
- Conflicting requirements between specs and implementation
**Example**:
```
**app/vault/ItemListViewModel.kt:67** - QUESTION: Expected sort behavior for equal timestamps?
When items have identical timestamps, should secondary sort be by:
- Name (alphabetical)
- Creation order
- Item type priority
Spec doesn't specify tie-breaking logic.
```
### Design Decisions
- Architecture choices that could go multiple ways
- Trade-offs between approaches without clear winner
- Feature flag strategy or rollout approach
- API design with multiple valid patterns
**Example**:
```
**data/sync/SyncManager.kt:134** - QUESTION: Should sync failures retry automatically?
Current implementation fails immediately. Options:
- Exponential backoff (3 retries)
- User-triggered retry only
- Background retry on network restore
What's the expected UX?
```
### System Integration
- Unclear contracts with external systems
- Potential conflicts with other features/modules
- Assumptions about third-party API behavior
- Cross-team coordination needs
**Example**:
```
**app/auth/BiometricPrompt.kt:89** - QUESTION: Compatibility with pending device credentials PR?
PR #1234 is refactoring device credentials. Should this:
- Merge first and adapt later
- Wait for #1234 to land
- Coordinate with that author
Timing unclear.
```
### Testing Strategy
- Uncertainty about test scope or approach
- Questions about mocking external dependencies
- Edge cases that need product input
- Performance testing requirements
**Example**:
```
**data/vault/EncryptionTest.kt:45** - QUESTION: Should we test against real Keystore?
Currently using mocked Keystore. Real Keystore testing would:
+ Catch hardware-specific issues
- Slow down CI significantly
- Require API 23+ emulators
What's the priority?
```
---
## Optional (Acknowledge But Don't Require)
Note good practices to reinforce positive patterns. Keep these **brief** - list only, no elaboration.
### Good Practices
**Format**: Simple bullet list, no explanation
```markdown
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
- Well-documented public APIs
```
**Don't do this** (too verbose):
```markdown
## Good Practices
- Proper Hilt DI usage throughout: Great job using @Inject constructor and injecting interfaces! This follows our established patterns perfectly and makes the code very testable. Really excellent work here! 👍
```
---
## Classification Guidelines
### When Something is Between Categories
**If unsure between Critical and Important**:
- Ask: "Could this cause production incidents, data loss, or security breaches?"
- If yes → Critical
- If no → Important
**If unsure between Important and Debt**:
- Ask: "Is this a bug/defect or just duplication/inconsistency?"
- If bug/defect → Important
- If duplication/inconsistency → Debt
**If unsure between Important and Suggested**:
- Ask: "Would I block merge over this?"
- If yes → Important
- If no → Suggested
**If unsure between Debt and Suggested**:
- Ask: "Will this require rework within 6 months?"
- If yes → Debt
- If no → Suggested
**If unsure between Suggested and Question**:
- Ask: "Am I requesting a change or asking for clarification?"
- If requesting change → Suggested
- If seeking clarification → Question
**If unsure between Suggested and Optional**:
- Ask: "Is this actionable feedback or just acknowledgment?"
- If actionable → Suggested
- If acknowledgment → Optional
### Context Matters
**Same issue, different contexts**:
```
// Critical for production code
Missing null safety check in auth flow → CRITICAL
// Suggested for internal test utility
Missing null safety check in test helper → SUGGESTED
```
**Same pattern, different risk levels**:
```
// Critical for new feature
Missing tests for new auth method → CRITICAL
// Important for bug fix
Missing regression test → IMPORTANT
// Suggested for refactoring
Missing tests for refactored helper → SUGGESTED
```
---
## Examples by Change Type
### Dependency Update
- **Critical**: Known CVEs in old version not addressed
- **Important**: Breaking changes that need migration
- **Suggested**: Beta/alpha version stability concerns
### Bug Fix
- **Critical**: Fix doesn't address root cause
- **Important**: Missing regression test
- **Suggested**: Similar bugs in related code
### Feature Addition
- **Critical**: Security vulnerabilities, architecture violations
- **Important**: Missing tests for critical paths
- **Suggested**: Additional test coverage, minor refactoring
### UI Refinement
- **Critical**: Missing accessibility for key actions
- **Important**: Not using theme (hardcoded colors)
- **Suggested**: Minor spacing/alignment improvements
### Refactoring
- **Critical**: Changes behavior (should be behavior-preserving)
- **Important**: Incomplete migration (mix of old/new patterns)
- **Suggested**: Additional instances that could be refactored
### Infrastructure
- **Critical**: Hardcoded secrets, no rollback plan
- **Important**: Performance regression in build times
- **Suggested**: Further optimization opportunities
---
## Special Cases
### Technical Debt
- Acknowledge existing tech debt but don't require fixing in unrelated PR
- Exception: If change makes tech debt worse, it's Important to address
### Scope Creep
- Don't request changes outside PR scope
- Can note as "Future consideration" but not required for this PR
### Linter-Catchable Issues
- Don't flag issues that automated tools handle
- Exception: If linter is misconfigured and missing real issues
### Personal Preferences
- Don't flag unless grounded in project standards or architectural principles
- Use "I-statements" if suggesting alternative approaches
---
## Summary
**Critical**: Block merge, must fix (security, stability, architecture)
**Important**: Should fix before merge (testing, quality, performance)
**Debt**: Technical debt introduced, track for future cleanup
**Suggested**: Nice to have, consider effort vs benefit
**Question**: Seeking clarification on requirements or design
**Optional**: Acknowledge good practices, keep brief

View File

@@ -0,0 +1,163 @@
# Review Psychology: Constructive Feedback Phrasing
Effective code review feedback is clear, actionable, and constructive. This guide provides phrasing patterns for inline comments.
## Table of Contents
**Guidelines:**
- [Phrasing Templates](#phrasing-templates)
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
- [Questions (Collaborative)](#questions-collaborative)
- [Test Suggestions](#test-suggestions)
- [When to Be Prescriptive vs Ask Questions](#when-to-be-prescriptive-vs-ask-questions)
- [Special Cases](#special-cases)
---
## Phrasing Templates
### Critical Issues (Prescriptive)
**Pattern**: State problem + Provide solution + Explain why
```
**[file:line]** - CRITICAL: [Issue description]
[Specific fix with code example if applicable]
[Rationale explaining why this is critical]
Reference: [docs link if applicable]
```
**Example**:
```
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
Plaintext storage exposes the PIN to backup systems and rooted devices.
Reference: docs/ARCHITECTURE.md#security
```
---
### Suggested Improvements (Exploratory)
**Pattern**: Observe + Suggest + Explain benefit
```
**[file:line]** - Consider [alternative approach]
[Current observation]
Can we [specific suggestion]?
[Benefit or rationale]
```
**Example**:
```
**app/login/LoginScreen.kt:89** - Consider using existing BitwardenButton
This custom button implementation looks similar to `ui/components/BitwardenButton.kt:45`.
Can we use the existing component to maintain consistency across the app?
```
---
### Questions (Collaborative)
**Pattern**: Ask + Provide context (optional)
```
**[file:line]** - [Question about intent or approach]?
[Optional context or observation]
```
**Example**:
```
**data/sync/SyncManager.kt:234** - How does this handle concurrent sync attempts?
It looks like multiple coroutines could call `startSync()` simultaneously.
Is there a mechanism to prevent race conditions, or is that handled elsewhere?
```
---
### Test Suggestions
**Pattern**: Observe gap + Suggest specific test + Provide skeleton
```
**[file:line]** - Consider adding test for [scenario]
[Rationale]
```kotlin
@Test
fun `test description`() = runTest {
// Test skeleton
}
```
```
**Example**:
```
**data/auth/BiometricRepository.kt** - Consider adding test for cancellation scenario
This would prevent regression of the bug you just fixed:
```kotlin
@Test
fun `when biometric cancelled then returns cancelled state`() = runTest {
coEvery { biometricPrompt.authenticate() } returns null
val result = repository.authenticate()
assertEquals(AuthResult.Cancelled, result)
}
```
```
---
## When to Be Prescriptive vs Ask Questions
**Be Prescriptive** (Tell them what to do):
- Security issues
- Architecture pattern violations
- Null safety problems
- Compilation errors
- Documented project standards
**Ask Questions** (Seek explanation):
- Design decisions with multiple valid approaches
- Performance trade-offs without data
- Unclear intent or reasoning
- Scope decisions (this PR vs future work)
- Patterns not documented in project guidelines
---
## Special Cases
**Nitpicks** - For truly minor suggestions, use "Nit:" prefix:
```
**Nit**: Extra blank line at line 145
```
**Uncertainty** - If unsure, acknowledge it:
```
I'm not certain, but this might be called frequently.
Has this been profiled?
```
**Positive Feedback** - Brief list only, no elaboration:
```
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
```

View File

@@ -0,0 +1,90 @@
# Security Patterns Quick Reference
Quick reference for Bitwarden Android security patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md#security`.
## Encryption and Key Storage
**✅ GOOD - Android Keystore**:
```kotlin
// Sensitive data encrypted with Keystore
class SecureStorage @Inject constructor(
private val keystoreManager: KeystoreManager
) {
suspend fun storePin(pin: String): Result<Unit> = runCatching {
val encrypted = keystoreManager.encrypt(pin.toByteArray())
securePreferences.putBytes(KEY_PIN, encrypted)
}
}
// Or use EncryptedSharedPreferences
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
```
**❌ BAD - Plaintext or weak encryption**:
```kotlin
// ❌ CRITICAL - Plaintext storage
sharedPreferences.edit {
putString("pin", userPin) // Never store sensitive data in plaintext
}
// ❌ CRITICAL - Weak encryption
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
// ❌ CRITICAL - Hardcoded keys
val key = "my_secret_key_123" // Use Android Keystore
```
**Key Rules**:
- Use Android Keystore for encryption keys
- Use EncryptedSharedPreferences for simple key-value storage
- Use AES-256-GCM for encryption
- Never store sensitive data in plaintext
- Never hardcode encryption keys
Reference: `docs/ARCHITECTURE.md#security`
---
## Logging Sensitive Data
**✅ GOOD - No sensitive data**:
```kotlin
Log.d(TAG, "Authentication attempt for user")
Log.d(TAG, "Vault sync completed with ${items.size} items")
```
**❌ BAD - Logs sensitive data**:
```kotlin
// ❌ CRITICAL
Log.d(TAG, "Password: $password")
Log.d(TAG, "Auth token: $token")
Log.d(TAG, "PIN: $pin")
Log.d(TAG, "Encryption key: ${key.encoded}")
```
**Key Rules**:
- Never log passwords, PINs, tokens, keys
- Never log encryption keys or sensitive data
- Be careful with error messages (don't include sensitive context)
---
## Quick Checklist
### Security
- [ ] Sensitive data encrypted with Keystore?
- [ ] No plaintext passwords/keys?
- [ ] No sensitive data in logs?
- [ ] Using AES-256-GCM for encryption?
- [ ] No hardcoded encryption keys?
---
For comprehensive security details, always refer to:
- `docs/ARCHITECTURE.md#security` - Complete security architecture and zero-knowledge principles

View File

@@ -0,0 +1,127 @@
# Testing Patterns Quick Reference
Quick reference for Bitwarden Android testing patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## ViewModel Tests
**✅ GOOD - Tests behavior**:
```kotlin
@Test
fun `when login succeeds then state updates to success`() = runTest {
// Arrange
val viewModel = LoginViewModel(mockRepository)
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
// Act
viewModel.onLoginClicked("user@example.com", "password")
// Assert
viewModel.state.test {
assertEquals(LoginState.Loading, awaitItem())
assertEquals(LoginState.Success, awaitItem())
}
}
```
**❌ BAD - Tests implementation**:
```kotlin
@Test
fun `repository is called with correct parameters`() {
// ❌ This tests implementation details, not behavior
viewModel.onLoginClicked("user", "pass")
coVerify { mockRepository.login("user", "pass") }
}
```
**Key Rules**:
- Test behavior, not implementation
- Use `runTest` for coroutine tests
- Use Turbine for Flow testing
- Use MockK for mocking
---
## Repository Tests
**✅ GOOD - Tests data transformations**:
```kotlin
@Test
fun `fetchItems maps API response to domain model`() = runTest {
// Arrange
val apiResponse = listOf(ApiItem(id = "1", name = "Test"))
coEvery { apiService.getItems() } returns apiResponse
// Act
val result = repository.fetchItems()
// Assert
assertTrue(result.isSuccess)
assertEquals(
listOf(DomainItem(id = "1", name = "Test")),
result.getOrThrow()
)
}
```
**Key Rules**:
- Test data transformations
- Test error handling (network failures, API errors)
- Test caching behavior if applicable
- Mock API services and databases
Reference: Project uses JUnit 5, MockK, Turbine, kotlinx-coroutines-test
---
## Null Safety
**✅ GOOD - Safe handling**:
```kotlin
// Safe call with elvis operator
val result = apiService.getData() ?: return State.Error("No data")
// Let with safe call
intent?.getStringExtra("key")?.let { value ->
processValue(value)
}
// Require with message
val data = requireNotNull(response.data) { "Response data must not be null" }
```
**❌ BAD - Unsafe assertions**:
```kotlin
// ❌ Unsafe - can crash
val result = apiService.getData()!!
// ❌ Platform type unchecked
val intent: Intent = getIntent() // Could be null from Java
val value = intent.getStringExtra("key") // Potential NPE
```
**Key Rules**:
- Avoid `!!` unless safety is guaranteed (rare)
- Handle platform types with explicit nullability
- Use safe calls (`?.`), elvis operator (`?:`), or explicit checks
- Use `requireNotNull` with descriptive message if crash is acceptable
---
## Quick Checklist
### Testing
- [ ] ViewModels have unit tests?
- [ ] Tests verify behavior, not implementation?
- [ ] Edge cases covered?
- [ ] Error scenarios tested?
### Code Quality
- [ ] Null safety handled properly (no `!!` without guarantee)?
- [ ] Public APIs have KDoc?
- [ ] Following naming conventions?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,85 @@
# Compose UI Patterns Quick Reference
Quick reference for Bitwarden Android Compose UI patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## Component Reuse
**✅ GOOD - Uses existing components**:
```kotlin
BitwardenButton(
text = "Submit",
onClick = onSubmit
)
BitwardenTextField(
value = text,
onValueChange = onTextChange,
label = "Email"
)
```
**❌ BAD - Duplicates existing components**:
```kotlin
// ❌ Recreating BitwardenButton
Button(
onClick = onSubmit,
colors = ButtonDefaults.buttonColors(
containerColor = BitwardenTheme.colorScheme.primary
)
) {
Text("Submit")
}
```
**Key Rules**:
- Check `:ui` module for existing components before creating custom ones
- Use BitwardenButton, BitwardenTextField, etc. for consistency
- Place new reusable components in `:ui` module
---
## Theme Usage
**✅ GOOD - Uses theme**:
```kotlin
Text(
text = "Title",
style = BitwardenTheme.typography.titleLarge,
color = BitwardenTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp)) // Standard spacing
```
**❌ BAD - Hardcoded values**:
```kotlin
Text(
text = "Title",
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Use theme
color = Color(0xFF0066FF) // Use theme color
)
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
```
**Key Rules**:
- Use `BitwardenTheme.colorScheme` for colors
- Use `BitwardenTheme.typography` for text styles
- Use standard spacing (4.dp, 8.dp, 16.dp, 24.dp)
---
## Quick Checklist
### UI Patterns
- [ ] Using existing Bitwarden components from `:ui` module?
- [ ] Using BitwardenTheme for colors and typography?
- [ ] Using standard spacing values (4, 8, 16, 24 dp)?
- [ ] No hardcoded colors or text styles?
- [ ] UI is stateless (observes state, doesn't modify)?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,320 @@
---
name: testing-android-code
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
version: 1.0.0
---
# Testing Android Code - Bitwarden Testing Patterns
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
## Test Framework Configuration
**Required Dependencies:**
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
---
## A. ViewModel Testing Patterns
### Base Class: BaseViewModelTest
**Always extend `BaseViewModelTest` for ViewModel tests.**
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
**Benefits:**
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
**Pattern:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.ButtonClick)
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
}
```
**For complete examples:** See `references/test-base-classes.md`
### StateFlow vs EventFlow (Critical Distinction)
| Flow Type | Replay | First Action | Pattern |
|-----------|--------|--------------|---------|
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
**For detailed patterns:** See `references/flow-testing-patterns.md`
---
## B. Compose UI Testing Patterns
### Base Class: BitwardenComposeTest
**Always extend `BitwardenComposeTest` for Compose screen tests.**
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
**Benefits:**
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed Clock for deterministic time-based tests
**Pattern:**
```kotlin
class ExampleScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `on back click should send BackClick action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
}
```
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
**For complete base class details:** See `references/test-base-classes.md`
---
## C. Repository and Service Testing
### Service Testing with MockWebServer
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
```kotlin
class ExampleServiceTest : BaseServiceTest() {
private val api: ExampleApi = retrofit.create()
private val service = ExampleServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
val result = service.getConfig()
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
}
```
### Repository Testing Pattern
```kotlin
class ExampleRepositoryTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val dispatcherManager = FakeDispatcherManager()
private val mockDiskSource: ExampleDiskSource = mockk()
private val mockService: ExampleService = mockk()
private val repository = ExampleRepositoryImpl(
clock = fixedClock,
exampleDiskSource = mockDiskSource,
exampleService = mockService,
dispatcherManager = dispatcherManager,
)
@Test
fun `fetchData should return success when service succeeds`() = runTest {
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
val result = repository.fetchData(userId)
assertTrue(result.isSuccess)
}
}
```
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
---
## D. Test Data Builders
### Builder Pattern with Number Parameter
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
```kotlin
fun createMockCipher(
number: Int,
id: String = "mockId-$number",
name: String? = "mockName-$number",
// ... more parameters with defaults
): SyncResponseJson.Cipher
// Usage:
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
val custom = createMockCipher(number = 3, name = "Custom")
```
**Available Builders (35+):**
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
---
## E. Result Type Testing
**Locations:**
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
```kotlin
// Create results
"data".asSuccess() // Result.success("data")
throwable.asFailure() // Result.failure<T>(throwable)
// Assertions
assertTrue(result.isSuccess)
assertEquals(expectedValue, result.getOrNull())
```
---
## F. Test Utilities and Helpers
### Fake Implementations
| Fake | Location | Purpose |
|------|----------|---------|
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
### Exception Testing (CRITICAL)
```kotlin
// CORRECT - Call directly, NOT inside runTest
@Test
fun `test exception`() {
assertCoroutineThrows<IllegalStateException> {
repository.throwingFunction()
}
}
```
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
---
## G. Critical Gotchas
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
**Core Patterns:**
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
**Assertion Patterns:**
- **Complete state assertions** - Assert entire state objects, not individual fields
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
**Test Design:**
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
- **Null stream testing** - Test null returns from ContentResolver operations
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
- **Test factory methods** - Accept domain state types, not SavedStateHandle
- **@Suppress("MaxLineLength")** - Only add when the `fun` declaration line **actually exceeds 100 chars** — do not copy the pattern blindly
---
## H. Test File Organization
### Directory Structure
```
module/src/test/kotlin/com/bitwarden/.../
├── ui/*ScreenTest.kt, *ViewModelTest.kt
├── data/repository/*RepositoryTest.kt
└── network/service/*ServiceTest.kt
module/src/testFixtures/kotlin/com/bitwarden/.../
├── util/TestHelpers.kt
├── base/Base*Test.kt
└── model/*Util.kt
```
### Test Naming
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
- Functions: `` `given state when action should result` ``
---
## Summary
Key Bitwarden-specific testing patterns:
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
3. **BaseServiceTest** - MockWebServer setup for network testing
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
---
## Reference Documentation
For detailed information, see:
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
**Complete Examples:**
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
- `examples/compose-screen-test-example.md` - Full Compose screen test
- `examples/repository-test-example.md` - Full repository test with mocks and fakes

View File

@@ -0,0 +1,337 @@
/**
* Complete Compose Screen Test Example
*
* Key patterns demonstrated:
* - Extending BitwardenComposeTest
* - Mocking ViewModel with flows
* - Testing UI interactions
* - Testing navigation callbacks
* - Using bufferedMutableSharedFlow for events
* - Testing dialogs with isDialog() and hasAnyAncestor()
*/
package com.bitwarden.example.feature
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Test
class ExampleScreenTest : BitwardenComposeTest() {
// Track navigation callbacks
private var haveCalledNavigateBack = false
private var haveCalledNavigateToNext = false
// Use bufferedMutableSharedFlow for events (default replay = 0)
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
// Mock ViewModel with relaxed = true
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
haveCalledNavigateBack = false
haveCalledNavigateToNext = false
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
onNavigateToNext = { haveCalledNavigateToNext = true },
viewModel = viewModel,
)
}
}
/**
* Test: Back button sends action to ViewModel
*/
@Test
fun `on back click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
/**
* Test: Submit button sends action to ViewModel
*/
@Test
fun `on submit click should send SubmitClick action`() {
composeTestRule
.onNodeWithText("Submit")
.performClick()
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
}
/**
* Test: Loading state shows progress indicator
*/
@Test
fun `loading state should display progress indicator`() {
mutableStateFlow.update { it.copy(isLoading = true) }
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
}
/**
* Test: Data state shows content
*/
@Test
fun `data state should display content`() {
mutableStateFlow.update { it.copy(data = "Test Data") }
composeTestRule
.onNodeWithText("Test Data")
.assertIsDisplayed()
}
/**
* Test: Error state shows error message
*/
@Test
fun `error state should display error message`() {
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
composeTestRule
.onNodeWithText("Something went wrong")
.assertIsDisplayed()
}
/**
* Test: NavigateBack event triggers navigation callback
*/
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
assertTrue(haveCalledNavigateBack)
}
/**
* Test: NavigateToNext event triggers navigation callback
*/
@Test
fun `NavigateToNext event should call onNavigateToNext`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
assertTrue(haveCalledNavigateToNext)
}
/**
* Test: Item in list can be clicked
*/
@Test
fun `on item click should send ItemClick action`() {
val itemId = "item-123"
mutableStateFlow.update {
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
}
composeTestRule
.onNodeWithText("Test Item")
.performClick()
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
}
// ==================== DIALOG TESTS ====================
/**
* Test: No dialog exists when dialogState is null
*/
@Test
fun `no dialog should exist when dialogState is null`() {
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
/**
* Test: Loading dialog displays when state updates
* PATTERN: Use isDialog() to check dialog exists
*/
@Test
fun `loading dialog should display when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
}
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify loading text within dialog using hasAnyAncestor(isDialog())
composeTestRule
.onAllNodesWithText("Please wait...")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Error dialog displays title and message
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
*/
@Test
fun `error dialog should display title and message`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "An error has occurred",
message = "Something went wrong. Please try again.",
),
)
}
// Verify dialog exists
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify title within dialog
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Verify message within dialog
composeTestRule
.onAllNodesWithText("Something went wrong. Please try again.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Dialog button click sends action
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
*/
@Test
fun `error dialog dismiss button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "Error",
message = "An error occurred",
),
)
}
// Click dismiss button within dialog
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
/**
* Test: Confirmation dialog with multiple buttons
* PATTERN: Test both confirm and cancel actions
*/
@Test
fun `confirmation dialog confirm button should send ConfirmAction`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure you want to proceed?",
),
)
}
// Click confirm button
composeTestRule
.onAllNodesWithText("Confirm")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
}
@Test
fun `confirmation dialog cancel button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure?",
),
)
}
// Click cancel button
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
items = emptyList(),
dialogState = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
val items: List<ExampleItem> = emptyList(),
val dialogState: DialogState? = null,
) {
/**
* PATTERN: Nested sealed class for dialog states.
* Common dialog types: Loading, Error, Confirmation
*/
sealed class DialogState {
data class Loading(val message: String) : DialogState()
data class Error(val title: String, val message: String) : DialogState()
data class Confirmation(val title: String, val message: String) : DialogState()
}
}
data class ExampleItem(val id: String, val name: String)
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class ItemClick(val itemId: String) : ExampleAction()
data object DismissDialog : ExampleAction()
data object ConfirmAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data object NavigateToNext : ExampleEvent()
}

View File

@@ -0,0 +1,255 @@
/**
* Complete Repository Test Example
*
* Key patterns demonstrated:
* - Fake for disk sources, Mock for network services
* - Using FakeDispatcherManager for deterministic coroutines
* - Using fixed Clock for deterministic time
* - Testing Result types with .asSuccess() / .asFailure()
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
* - Testing Flow emissions with Turbine
*/
package com.bitwarden.example.data.repository
import app.cash.turbine.test
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
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
class ExampleRepositoryTest {
// Fixed clock for deterministic time-based tests
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
// Use FakeDispatcherManager for deterministic coroutine execution
private val dispatcherManager = FakeDispatcherManager()
// Mock service (network layer is always mocked)
private val mockService: ExampleService = mockk()
/**
* PATTERN: Use Fake for disk source in happy path tests.
* This is the Bitwarden convention for repository testing.
*/
private val fakeDiskSource = FakeExampleDiskSource()
private lateinit var repository: ExampleRepositoryImpl
@BeforeEach
fun setup() {
repository = ExampleRepositoryImpl(
clock = fixedClock,
service = mockService,
diskSource = fakeDiskSource,
dispatcherManager = dispatcherManager,
)
}
// ==================== HAPPY PATH TESTS (use Fake) ====================
/**
* Test: Successful fetch returns data and saves to disk
*/
@Test
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns expectedData.asSuccess()
val result = repository.fetchData()
assertEquals(expectedData, result.getOrThrow())
// Fake automatically stores the data - verify it's there
assertEquals(expectedData, fakeDiskSource.storedData)
}
/**
* Test: Service failure returns failure without saving
*/
@Test
fun `fetchData should return failure when service fails`() = runTest {
val exception = Exception("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake was not updated
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Repository flow emits when disk source updates
*/
@Test
fun `dataFlow should emit when disk source updates`() = runTest {
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
repository.dataFlow.test {
// Initial null value from Fake
assertNull(awaitItem())
// Update via Fake property setter (triggers emission)
fakeDiskSource.storedData = data1
assertEquals(data1, awaitItem())
// Another update
fakeDiskSource.storedData = data2
assertEquals(data2, awaitItem())
}
}
/**
* Test: Refresh fetches and saves new data
*/
@Test
fun `refresh should fetch new data and update disk source`() = runTest {
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns newData.asSuccess()
val result = repository.refresh()
assertEquals(Unit, result.getOrThrow())
coVerify { mockService.getData() }
assertEquals(newData, fakeDiskSource.storedData)
}
/**
* Test: Delete clears data from disk
*/
@Test
fun `deleteData should clear disk source`() = runTest {
// Pre-populate the fake
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
repository.deleteData()
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Cached data returns from disk when available
*/
@Test
fun `getCachedData should return disk data without network call`() = runTest {
val cachedData = ExampleData(
id = "cached",
name = "Cached",
updatedAt = fixedClock.instant(),
)
fakeDiskSource.storedData = cachedData
val result = repository.getCachedData()
assertEquals(cachedData, result)
coVerify(exactly = 0) { mockService.getData() }
}
// ==================== ERROR PATH TESTS ====================
/**
* PATTERN: For error paths, reconfigure the class-level mock per-test.
* Use coEvery to change mock behavior for each specific test case.
*/
@Test
fun `fetchData should return failure when service returns error`() = runTest {
val exception = Exception("Server unavailable")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake state unchanged on failure
assertNull(fakeDiskSource.storedData)
}
@Test
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
// Pre-populate cache via Fake
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
fakeDiskSource.storedData = cachedData
// Reconfigure mock to return failure
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
val result = repository.refresh()
assertTrue(result.isFailure)
// Cached data preserved on failure
assertEquals(cachedData, fakeDiskSource.storedData)
}
}
// Example types (normally in separate files)
data class ExampleData(
val id: String,
val name: String,
val updatedAt: Instant,
)
interface ExampleService {
suspend fun getData(): Result<ExampleData>
}
interface ExampleDiskSource {
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
fun getData(): ExampleData?
fun saveData(data: ExampleData)
fun clearData()
}
/**
* PATTERN: Fake implementation for happy path testing.
*
* Key characteristics:
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
* - Uses .onSubscription { emit(state) } for immediate state emission
* - Private storage with override property setter that emits to flow
* - Test assertions done via the override property getter
*/
class FakeExampleDiskSource : ExampleDiskSource {
private var storedDataValue: ExampleData? = null
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
/**
* Override property with getter/setter. Setter emits to flow automatically.
* Tests can read this property for assertions and write to trigger emissions.
*/
var storedData: ExampleData?
get() = storedDataValue
set(value) {
storedDataValue = value
mutableDataFlow.tryEmit(value)
}
override val dataFlow: Flow<ExampleData?>
get() = mutableDataFlow.onSubscription { emit(storedData) }
override fun getData(): ExampleData? = storedData
override fun saveData(data: ExampleData) {
storedData = data
}
override fun clearData() {
storedData = null
}
}

View File

@@ -0,0 +1,161 @@
/**
* Complete ViewModel Test Example
*
* Key patterns demonstrated:
* - Extending BaseViewModelTest
* - Testing StateFlow with Turbine
* - Testing EventFlow with Turbine
* - Using stateEventFlow() for simultaneous testing
* - MockK mocking patterns
* - Test factory method design (accepts domain state, not SavedStateHandle)
* - Complete state assertions (assert entire state objects)
*/
package com.bitwarden.example.feature
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository: ExampleRepository = mockk()
private val mockAuthDiskSource: AuthDiskSource = mockk {
every { userStateFlow } returns MutableStateFlow(null)
}
/**
* StateFlow has replay=1, so first awaitItem() returns current state
*/
@Test
fun `initial state should be default state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
/**
* Test state transitions: initial -> loading -> success
*/
@Test
fun `LoadData action should update state from idle to loading to success`() = runTest {
val expectedData = "loaded data"
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.LoadData)
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
/**
* EventFlow has no replay - MUST call expectNoEvents() first
*/
@Test
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents() // CRITICAL for EventFlow
viewModel.trySendAction(ExampleAction.SubmitClick)
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
}
}
/**
* Use stateEventFlow() helper for simultaneous testing
*/
@Test
fun `complex action should update state and emit event`() = runTest {
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
eventFlow.expectNoEvents()
viewModel.trySendAction(ExampleAction.ComplexAction)
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
}
}
/**
* Test state restoration from saved state.
* Note: Use initialState parameter, NOT SavedStateHandle directly.
*/
@Test
fun `initial state from saved state should be preserved`() = runTest {
// Build complete expected state - always assert full objects
val savedState = ExampleState(
isLoading = false,
data = "restored data",
errorMessage = null,
)
val viewModel = createViewModel(initialState = savedState)
viewModel.stateFlow.test {
assertEquals(savedState, awaitItem())
}
}
/**
* Factory method accepts domain state, NOT SavedStateHandle.
* This hides Android framework details from test logic.
*/
private fun createViewModel(
initialState: ExampleState? = null,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
authDiskSource = mockAuthDiskSource,
)
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
)
sealed class ExampleAction {
data object LoadData : ExampleAction()
data object SubmitClick : ExampleAction()
data object ComplexAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateToNext : ExampleEvent()
data class ShowToast(val message: String) : ExampleEvent()
}

View File

@@ -0,0 +1,698 @@
# Critical Gotchas and Anti-Patterns
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
## ❌ NEVER wrap assertCoroutineThrows in runTest
### The Problem
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
### Wrong
```kotlin
@Test
fun `test exception`() = runTest {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Won't work - exception is caught by runTest!
}
```
### Correct
```kotlin
@Test
fun `test exception`() {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Works correctly
}
```
### Why This Happens
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
## ❌ ALWAYS unmock static functions
### The Problem
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
### Wrong
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
// Forgot @After - subsequent tests will fail mysteriously!
```
### Correct
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
@After
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
}
```
### Common Static Functions to Watch
```kotlin
// Platform version checks
mockkStatic(::isBuildVersionAtLeast)
unmockkStatic(::isBuildVersionAtLeast)
// URI parsing
mockkStatic(Uri::class)
unmockkStatic(Uri::class)
// Static utility functions
mockkStatic(MyUtilClass::class)
unmockkStatic(MyUtilClass::class)
```
### Debugging Tip
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
## ❌ Don't confuse StateFlow and EventFlow testing
### StateFlow (replay = 1)
```kotlin
// CORRECT - StateFlow always has current value
viewModel.stateFlow.test {
val initial = awaitItem() // Gets current state immediately
viewModel.trySendAction(action)
val updated = awaitItem() // Gets new state
}
```
### EventFlow (no replay)
```kotlin
// CORRECT - EventFlow has no initial value
viewModel.eventFlow.test {
expectNoEvents() // MUST do this first
viewModel.trySendAction(action)
val event = awaitItem() // Gets emitted event
}
```
### Common Mistake
```kotlin
// WRONG - Forgetting expectNoEvents() on EventFlow
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May cause flaky tests
assertEquals(event, awaitItem())
}
```
## ❌ Don't mix real and test dispatchers
### Wrong
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
)
@Test
fun `test repository`() = runTest {
// Test will have timing issues - real dispatcher != test dispatcher
}
```
### Correct
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
)
@Test
fun `test repository`() = runTest {
// Test runs deterministically
}
```
### Why This Matters
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
- Non-deterministic test failures
- Real delays in tests (slow test suite)
- Race conditions
### Always Use
- `FakeDispatcherManager()` for repositories
- `UnconfinedTestDispatcher()` when manually creating dispatchers
- `runTest` for coroutine tests (provides TestDispatcher automatically)
## ❌ Don't forget to use runTest for coroutine tests
### Wrong
```kotlin
@Test
fun `test coroutine`() {
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
}
```
This causes:
- Test completes before coroutines finish
- False positives (test passes but assertions never run)
- Mysterious failures
### Correct
```kotlin
@Test
fun `test coroutine`() = runTest {
viewModel.stateFlow.test { /* ... */ }
}
```
### When runTest is Required
- Testing ViewModels (they use `viewModelScope`)
- Testing Flows with Turbine `.test {}`
- Testing repositories with suspend functions
- Any test calling suspend functions
### Exception: assertCoroutineThrows
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
## ❌ Don't forget relaxed = true for complex mocks
### Without relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
// Error: "no answer found for: stateFlow"
```
### With relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
// Only mock what you care about
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
```
### When to Use relaxed
- Mocking ViewModels in Compose tests
- Mocking complex objects with many methods
- When you only care about specific method calls
### When NOT to Use relaxed
- Mocking repository interfaces (be explicit about behavior)
- When you want to verify NO unexpected calls
- Testing error paths (want test to fail if unexpected method called)
## ❌ Don't assert individual fields when complete state is available
### The Problem
Asserting individual state fields can miss unintended side effects on other fields.
### Wrong
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val state = viewModel.stateFlow.value
assertEquals(null, state.dialog) // Only checks one field!
}
```
### Correct
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val expected = SomeState(
isLoading = false,
data = "result",
dialog = null,
)
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
}
```
### Why This Matters
- Catches unintended mutations to other state fields
- Makes expected state explicit and readable
- Prevents silent regressions when state structure changes
---
## ❌ Don't use Kotlin assert() for boolean checks
### The Problem
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
### Wrong
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assert(onNavigateCalled) // Kotlin assert - bad failure messages
}
```
### Correct
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
}
```
### Always Use JUnit Assertions
- `assertTrue()` / `assertFalse()` for booleans
- `assertEquals()` for value comparisons
- `assertNotNull()` / `assertNull()` for nullability
- `assertThrows<T>()` for exceptions
---
## ❌ Don't pass SavedStateHandle to test factory methods
### The Problem
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
### Wrong
```kotlin
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
): MyViewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
// ...
}
```
### Correct
```kotlin
private fun createViewModel(
initialState: MyState? = null, // Domain type only
): MyViewModel = MyViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val viewModel = createViewModel(initialState = savedState)
// ...
}
```
### Why This Matters
- Cleaner, more intuitive test code
- Hides SavedStateHandle implementation details
- Follows Bitwarden conventions
---
## ❌ Don't test SavedStateHandle persistence in unit tests
### The Problem
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
### Wrong
```kotlin
@Test
fun `state should persist to SavedStateHandle`() = runTest {
val savedStateHandle = SavedStateHandle()
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
viewModel.trySendAction(SomeAction)
val savedState = savedStateHandle.get<MyState>("state")
assertEquals(expectedState, savedState) // Testing framework, not logic!
}
```
### Correct
Focus on testing business logic and state transformations:
```kotlin
@Test
fun `action should update state correctly`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SomeAction)
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
}
```
---
## ❌ Don't use static mocking when DI pattern is available
### The Problem
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
### Wrong
```kotlin
class ParserTest {
@BeforeEach
fun setup() {
mockkStatic(UUID::class)
every { UUID.randomUUID() } returns mockk {
every { toString() } returns "fixed-uuid"
}
}
@AfterEach
fun tearDown() {
unmockkStatic(UUID::class)
}
}
```
### Correct
Extract an interface and inject it:
```kotlin
// Production code
interface UuidManager {
fun generateUuid(): String
}
class UuidManagerImpl : UuidManager {
override fun generateUuid(): String = UUID.randomUUID().toString()
}
class Parser(private val uuidManager: UuidManager) { ... }
// Test code
class ParserTest {
private val mockUuidManager = mockk<UuidManager>()
@BeforeEach
fun setup() {
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
}
// No tearDown needed - no static mocking!
}
```
### When to Use This Pattern
- UUID generation
- Timestamp/Clock operations
- System property access
- Any static function that needs deterministic testing
---
## ❌ Don't forget to test null stream returns from Android APIs
### The Problem
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
### Wrong
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
// Missing: test for null return!
}
```
### Correct
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
@Test
fun `stringToUri with null stream should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } returns null
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result) // CRITICAL: must handle null!
}
}
```
### Common Android APIs That Return Null
- `ContentResolver.openOutputStream()` / `openInputStream()`
- `Context.getExternalFilesDir()`
- `PackageManager.getApplicationInfo()` (can throw)
---
## Bitwarden Mocking Guidelines
**Mock at architectural boundaries:**
- Repository → ViewModel (mock repository)
- Service → Repository (mock service)
- API → Service (use MockWebServer, not mocks)
- DiskSource → Repository (mock disk source)
**Fake vs Mock Strategy (IMPORTANT):**
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
- **Error paths**: Use MockK with isolated repository instances
```kotlin
// Happy path - use Fake
private val fakeDiskSource = FakeAuthenticatorDiskSource()
@Test
fun `createItem should return Success`() = runTest {
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Success, result)
}
// Error path - use isolated Mock
@Test
fun `createItem with exception should return Error`() = runTest {
val mockDiskSource = mockk<AuthenticatorDiskSource> {
coEvery { saveItem(any()) } throws RuntimeException()
}
val repository = RepositoryImpl(diskSource = mockDiskSource)
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Error, result)
}
```
**Use Fakes for:**
- `FakeDispatcherManager` - deterministic coroutines
- `FakeConfigDiskSource` - in-memory config storage
- `FakeSharedPreferences` - memory-backed preferences
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
**Create real instances for:**
- Data classes, value objects (User, Config, CipherView)
- Test data builders (`createMockCipher(number = 1)`)
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
### The Problem
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
### Wrong
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = MutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems)
}
}
// Test: Initial collection gets nothing!
repository.dataFlow.test {
// Hangs or fails - no initial emission
}
```
### Correct
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
.onSubscription { emit(storedItems.toList()) }
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems.toList())
}
}
// Test: Initial collection receives current state
repository.dataFlow.test {
assertEquals(emptyList(), awaitItem()) // Works!
}
```
### Key Points
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
- Add `.onSubscription { emit(currentState) }` for immediate state emission
- This ensures new collectors receive the current cached state
---
## ✅ Use Result extension functions for assertions
### The Pattern
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
### Success Path
```kotlin
@Test
fun `getData should return success`() = runTest {
val result = repository.getData()
val expected = expectedData.asSuccess()
assertEquals(expected.getOrNull(), result.getOrNull())
}
```
### Failure Path
```kotlin
@Test
fun `getData with error should return failure`() = runTest {
val exception = IOException("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.getData()
assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}
```
### Avoid Redundant Assertions
```kotlin
// WRONG - redundant success checks
assertTrue(result.isSuccess)
assertTrue(expected.isSuccess)
assertArrayEquals(expected.getOrNull(), result.getOrNull())
// CORRECT - final assertion is sufficient
assertArrayEquals(expected.getOrNull(), result.getOrNull())
```
---
## Summary Checklist
Before submitting tests, verify:
**Core Patterns:**
- [ ] No `assertCoroutineThrows` inside `runTest`
- [ ] All static mocks have `unmockk` in `@After`
- [ ] EventFlow tests start with `expectNoEvents()`
- [ ] Using FakeDispatcherManager, not real dispatchers
- [ ] All coroutine tests use `runTest`
**Assertion Patterns:**
- [ ] Assert complete state objects, not individual fields
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
- [ ] Use `asSuccess()` for Result type assertions
- [ ] Avoid redundant assertion patterns
**Test Design:**
- [ ] Test factory methods accept domain types, not SavedStateHandle
- [ ] Use Fakes for happy paths, Mocks for error paths
- [ ] Prefer DI patterns over static mocking
- [ ] Test null returns from Android APIs (streams, files)
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
**General:**
- [ ] Tests don't depend on execution order
- [ ] Complex mocks use `relaxed = true`
- [ ] Test data is created fresh for each test
- [ ] Mocking behavior, not value objects
- [ ] Testing observable behavior, not implementation
When tests fail mysteriously, check these gotchas first.

View File

@@ -0,0 +1,274 @@
# Flow Testing with Turbine
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
## StateFlow vs EventFlow
### StateFlow (Replayed)
**Characteristics:**
- `replay = 1` - Always emits current value to new collectors
- First `awaitItem()` returns the current/initial state
- Survives configuration changes
- Used for UI state that needs to be immediately available
**Test Pattern:**
```kotlin
@Test
fun `action should update state`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
// First awaitItem() gets CURRENT state
assertEquals(INITIAL_STATE, awaitItem())
// Trigger action
viewModel.trySendAction(MyAction.LoadData)
// Next awaitItem() gets UPDATED state
assertEquals(LOADING_STATE, awaitItem())
assertEquals(SUCCESS_STATE, awaitItem())
}
}
```
### EventFlow (No Replay)
**Characteristics:**
- `replay = 0` - Only emits new events after subscription
- No initial value emission
- One-time events (navigation, toasts, dialogs)
- Does not survive configuration changes
**Test Pattern:**
```kotlin
@Test
fun `action should emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.eventFlow.test {
// MUST call expectNoEvents() first - nothing emitted yet
expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.Submit)
// Now expect the event
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
## Testing State and Events Simultaneously
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.ComplexAction)
// Verify state progression
assertEquals(LOADING_STATE, stateFlow.awaitItem())
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
// Verify event emission
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
}
}
```
## Repository Flow Testing
### Testing Database Flows
```kotlin
@Test
fun `dataFlow should emit when database updates`() = runTest {
val dataFlow = MutableStateFlow(initialData)
every { mockDiskSource.dataFlow } returns dataFlow
repository.dataFlow.test {
// Initial value
assertEquals(initialData, awaitItem())
// Update disk source
dataFlow.value = updatedData
// Verify emission
assertEquals(updatedData, awaitItem())
}
}
```
### Testing Transformed Flows
```kotlin
@Test
fun `flow transformation should map correctly`() = runTest {
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
every { mockDao.observeUser() } returns sourceFlow
// Repository transforms entity to domain model
repository.userFlow.test {
val expectedUser = User(id = "1", name = "John")
assertEquals(expectedUser, awaitItem())
}
}
```
## Common Patterns
### Pattern 1: Testing Initial State + Action
```kotlin
@Test
fun `load data should update from idle to loading to success`() = runTest {
coEvery { repository.getData() } returns "data".asSuccess()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
}
}
```
### Pattern 2: Testing Error States
```kotlin
@Test
fun `load data with error should emit failure state`() = runTest {
val error = Exception("Network error")
coEvery { repository.getData() } returns error.asFailure()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
awaitItem(),
)
}
}
```
### Pattern 3: Testing Event Sequences
```kotlin
@Test
fun `submit should emit validation then navigation events`() = runTest {
viewModel.eventFlow.test {
expectNoEvents()
viewModel.trySendAction(MyAction.Submit)
assertEquals(MyEvent.ShowValidation, awaitItem())
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
### Pattern 4: Testing Cancellation
```kotlin
@Test
fun `cancelling collection should stop emissions`() = runTest {
val flow = flow {
repeat(100) {
emit(it)
delay(100)
}
}
flow.test {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
// Cancel after 2 items
cancel()
// No more items received
}
}
```
## Anti-Patterns
### ❌ Forgetting expectNoEvents() on EventFlow
```kotlin
// WRONG
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
assertEquals(event, awaitItem())
}
// CORRECT
viewModel.eventFlow.test {
expectNoEvents() // ALWAYS do this first
viewModel.trySendAction(action)
assertEquals(event, awaitItem())
}
```
### ❌ Not Using runTest
```kotlin
// WRONG - Missing runTest
@Test
fun `test flow`() {
flow.test { /* ... */ }
}
// CORRECT
@Test
fun `test flow`() = runTest {
flow.test { /* ... */ }
}
```
### ❌ Mixing StateFlow and EventFlow Patterns
```kotlin
// WRONG - Treating StateFlow like EventFlow
stateFlow.test {
expectNoEvents() // Unnecessary - StateFlow always has value
/* ... */
}
// WRONG - Treating EventFlow like StateFlow
eventFlow.test {
val item = awaitItem() // Will hang - no initial value!
/* ... */
}
```
## Reference Implementations
**ViewModel with StateFlow and EventFlow:**
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Repository Flow Testing:**
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
**Complex Flow Transformations:**
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`

View File

@@ -0,0 +1,259 @@
# Test Base Classes Reference
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
## BaseViewModelTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
### Purpose
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
### Automatic Configuration
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Ensures deterministic coroutine execution in tests
- All coroutines complete immediately without real delays
### Key Feature: stateEventFlow() Helper
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Verify initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(ExampleAction.ComplexAction)
// Verify state updated
assertEquals(LOADING_STATE, stateFlow.awaitItem())
// Verify event emitted
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
}
}
```
### Usage Pattern
```kotlin
class MyViewModelTest : BaseViewModelTest() {
private val mockRepository: MyRepository = mockk()
private val savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to INITIAL_STATE)
)
@Test
fun `test action`() = runTest {
val viewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository
)
// Test with automatic dispatcher setup
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
}
}
}
```
## BitwardenComposeTest
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
### Purpose
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
### Automatic Configuration
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed `Clock` for deterministic time-based tests
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
### Key Features
**Pre-configured Managers:**
- `FeatureFlagManager` - Controls feature flag behavior
- `AuthTabManager` - Manages auth tab state
- `BiometricsManager` - Handles biometric authentication
- `ClipboardManager` - Clipboard operations
- `NotificationManager` - Notification display
**Fixed Clock:**
All tests use a fixed clock for deterministic time-based testing:
```kotlin
// Tests use consistent time: 2023-10-27T12:00:00Z
val fixedClock: Clock
```
### Usage Pattern
```kotlin
class MyScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<MyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
MyScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel
)
}
}
@Test
fun `on back click should send action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(MyAction.BackClick) }
}
@Test
fun `loading state should show progress`() {
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
}
}
```
### Important: bufferedMutableSharedFlow for Events
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
```kotlin
// Correct for Compose tests
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
// This allows triggering events and having the UI react
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
```
## BaseServiceTest
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
### Purpose
Provides MockWebServer setup for testing API service implementations.
### Automatic Configuration
- `server: MockWebServer` - Auto-started before each test, stopped after
- `retrofit: Retrofit` - Pre-configured with:
- JSON converter (kotlinx.serialization)
- NetworkResultCallAdapter for Result<T> responses
- Base URL pointing to MockWebServer
- `json: Json` - kotlinx.serialization JSON instance
### Usage Pattern
```kotlin
class MyServiceTest : BaseServiceTest() {
private val api: MyApi = retrofit.create()
private val service = MyServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
// Enqueue mock response
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
// Call service
val result = service.getConfig()
// Verify result
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
@Test
fun `getConfig should return failure when API fails`() = runTest {
// Enqueue error response
server.enqueue(MockResponse().setResponseCode(500))
// Call service
val result = service.getConfig()
// Verify failure
assertTrue(result.isFailure)
}
}
```
### MockWebServer Patterns
**Enqueue successful response:**
```kotlin
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
```
**Enqueue error response:**
```kotlin
server.enqueue(MockResponse().setResponseCode(404))
server.enqueue(MockResponse().setResponseCode(500))
```
**Enqueue delayed response:**
```kotlin
server.enqueue(
MockResponse()
.setBody("""{"key": "value"}""")
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
)
```
**Verify request details:**
```kotlin
val request = server.takeRequest()
assertEquals("/api/config", request.path)
assertEquals("GET", request.method)
assertEquals("Bearer token", request.getHeader("Authorization"))
```
## BaseComposeTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
### Purpose
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
### Features
- Robolectric configuration for Compose
- Proper dispatcher setup
- `composeTestRule` for UI testing
- `setTestContent()` helper wraps content in theme
### Usage
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
## When to Use Each Base Class
| Test Type | Base Class | Use When |
|-----------|------------|----------|
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
## Complete Examples
**ViewModel Test:**
`../examples/viewmodel-test-example.md`
**Compose Screen Test:**
`../examples/compose-screen-test-example.md`
**Repository Test:**
`../examples/repository-test-example.md`

View File

@@ -9,27 +9,3 @@
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## ⏰ Reminders before review
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## 🦮 Reviewer guidelines
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

View File

@@ -11,10 +11,14 @@ runs:
steps:
- name: Log inputs to job summary
shell: bash
env:
INPUTS: ${{ inputs.inputs }}
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ inputs.inputs }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -8,27 +8,8 @@ inputs:
runs:
using: 'composite'
steps:
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
@@ -44,6 +25,5 @@ runs:
- name: Install Fastlane
shell: bash
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

58
.github/label-pr.json vendored Normal file
View File

@@ -0,0 +1,58 @@
{
"title_patterns": {
"t:feature": ["feat", "feature", "tool"],
"t:bug": ["fix", "bug", "bugfix"],
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
"t:docs": ["docs"],
"t:ci": ["ci", "build", "chore(ci)"],
"t:deps": ["deps"],
"t:breaking-change": ["breaking", "breaking-change"],
"t:misc": ["misc"],
"t:llm": ["llm"]
},
"path_patterns": {
"app:shared": [
"annotation/",
"core/",
"data/",
"network/",
"ui/",
"authenticatorbridge/",
"gradle/"
],
"app:password-manager": [
"app/",
"cxf/",
"testharness/"
],
"app:authenticator": [
"authenticator/"
],
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json",
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
"detekt-config.yml"
],
"t:docs": [
"docs/"
],
"t:deps": [
"gradle/"
],
"t:llm": [
".claude/"
]
}
}

34
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: '✨ Community Highlight'
labels:
- community-pr
- title: ':shipit: Feature Development'
labels:
- t:feature
- t:feature-app
- t:feature-tool
- t:new-feature
- t:enhancement
- title: '❗ Breaking Changes'
labels:
- t:breaking-change
- title: '🐛 Bug fixes'
labels:
- t:bug
- title: '⚙️ Maintenance'
labels:
- t:tech-debt
- t:ci
- t:docs
- t:misc
- title: '📦 Dependency Updates'
labels:
- dependencies
- t:deps
- title: '🎨 Other'
labels:
- '*'

14
.github/renovate.json vendored
View File

@@ -3,6 +3,7 @@
"extends": [
"github>bitwarden/renovate-config"
],
"ignoreDeps": ["com.bitwarden:sdk-android"],
"enabledManagers": [
"github-actions",
"gradle",
@@ -19,19 +20,6 @@
"patch"
]
},
{
"groupName": "gradle minor",
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"gradle"
],
"excludePackageNames": [
"com.github.bumptech.glide:compose"
]
},
{
"groupName": "kotlin",
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# Requires Python 3.9+
"""
Comment GitHub issues linked to Pull Requests mentioned in a given release.
Usage:
python gh_release_update_issues.py <release_url> [--dry-run]
Arguments:
release-url: The URL of the release to comment on
--dry-run: Run without actually updating issues
Examples:
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0 --dry-run
"""
import re
import subprocess
import json
import argparse
from collections import defaultdict
from typing import List, Tuple, Dict
def parse_release_url(release_url: str) -> Tuple[str, str, str]:
"""Extract owner, repo name, and tag from a GitHub release URL.
Returns:
Tuple of (owner, repo_name, release_tag)
"""
match = re.search(r'github\.com/([\w-]+)/([\w.-]+)/releases/tag/(.+)$', release_url)
if not match:
raise ValueError(f"Cannot parse release URL: {release_url}")
return match.group(1), match.group(2), match.group(3)
def extract_pr_numbers(release_notes: str) -> List[int]:
return [int(n) for n in re.findall(r'/pull/(\d+)', release_notes)]
def build_issue_comment(repo: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str:
if len(pr_numbers) == 0:
return ""
pr_links = [f"* https://github.com/{repo}/pull/{pr_number}" for pr_number in pr_numbers]
return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links)
def gh_fetch_release(repo: str, release_tag: str) -> Tuple[str, str]:
result = subprocess.run(
['gh', 'release', 'view', release_tag, '--repo', repo, '--json', 'name,body'],
capture_output=True, text=True, check=True
)
data = json.loads(result.stdout)
return data['name'], data['body']
def gh_comment_issue(repo: str, issue_number: int, comment: str) -> None:
"""Use GitHub CLI to comment on an issue.
"""
subprocess.run([
'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', repo
], check=True)
def gh_fetch_linked_issues_batched(owner: str, repo_name: str, pr_numbers: List[int]) -> Dict[int, List[int]]:
"""Batch-fetch linked issues for all PRs in a single GraphQL call.
Returns:
Dict mapping each PR number to its list of linked issue numbers.
"""
if not pr_numbers:
return {}
tmpl = 'pr_%d: pullRequest(number: %d) { closingIssuesReferences(first: 100) { nodes { number } } }'
pr_fragments = "\n".join(tmpl % (pr, pr) for pr in pr_numbers)
query = """
query ($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
%s
}
}
""" % pr_fragments
try:
result = subprocess.run(
[
'gh', 'api', 'graphql',
'-F', f'owner={owner}',
'-F', f'repo={repo_name}',
'-f', f'query={query}',
],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout)
repo_data = data['data']['repository']
pr_issues_map: Dict[int, List[int]] = {}
for pr_number in pr_numbers:
nodes = repo_data.get(f'pr_{pr_number}', {}).get('closingIssuesReferences', {}).get('nodes', [])
pr_issues = [node['number'] for node in nodes]
pr_issues_map[pr_number] = pr_issues
return pr_issues_map
except subprocess.CalledProcessError as e:
print(f"::error::Error batch-fetching linked issues: {e.stderr}")
raise
def map_issues_to_prs(pr_issues_map: Dict[int, List[int]]) -> Dict[int, List[int]]:
"""Invert a PR->issues map into an issue->PRs map."""
issue_pr_map: Dict[int, List[int]] = defaultdict(list)
for pr_number, issue_numbers in pr_issues_map.items():
for issue_number in issue_numbers:
issue_pr_map[issue_number].append(pr_number)
return dict(issue_pr_map)
def comment_issues(repo: str, issue_pr_map: Dict[int, List[int]], release_name: str, release_url: str, dry_run: bool) -> None:
for issue_number, linked_prs in issue_pr_map.items():
comment = build_issue_comment(repo, release_name, release_url, linked_prs)
print(f"{'Dry run - ' if dry_run else ''}Commenting on issue {issue_number}:\n{comment}\n")
if not dry_run and comment:
gh_comment_issue(repo, issue_number, comment)
def parse_args():
parser = argparse.ArgumentParser(
description='Comment GitHub issues linked to Pull Requests mentioned in a given release.'
)
parser.add_argument(
'release_url',
help='Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Run without actually commenting issues'
)
return parser.parse_args()
if __name__ == '__main__':
args = parse_args()
owner, repo_name, release_tag = parse_release_url(args.release_url)
repo = f"{owner}/{repo_name}"
print(f"📋 Release URL: {args.release_url}")
release_name, release_notes = gh_fetch_release(repo, release_tag)
print(f"📋 Release Name: {release_name}")
pr_numbers = extract_pr_numbers(release_notes)
print(f"📋 PR Numbers parsed from release notes: {pr_numbers}")
pr_issues_map = gh_fetch_linked_issues_batched(owner, repo_name, pr_numbers)
print(f"📋 PRs with linked issues: {[pr for pr, issues in pr_issues_map.items() if issues]}\n")
issue_pr_map = map_issues_to_prs(pr_issues_map)
comment_issues(repo, issue_pr_map, release_name, args.release_url, args.dry_run)

View File

@@ -40,7 +40,7 @@ Single line of release notes text
```json
...
"customfield_10335": {
"customfield_9999": {
"type": "doc",
"version": 1,
"content": [
@@ -62,7 +62,7 @@ Single line of release notes text
```json
...
"customfield_10335": {
"customfield_9999": {
"type": "doc",
"version": 1,
"content": [

View File

@@ -5,6 +5,8 @@ import base64
import json
import requests
SCRIPT_NAME = "jira_release_notes.py"
def extract_text_from_content(content):
if isinstance(content, list):
texts = [extract_text_from_content(item) for item in content]
@@ -23,19 +25,42 @@ def extract_text_from_content(content):
return ''
def parse_release_notes(response_json):
try:
fields = response_json.get('fields', {})
release_notes_field = fields.get('customfield_10335', {})
def log_customfields_with_content(fields):
"""Log all customfield_* fields that have a 'content' key to help troubleshoot structure changes."""
print(f"[{SCRIPT_NAME}] Available customfield_* fields with 'content':", file=sys.stderr)
found = False
for key, value in fields.items():
if key.startswith('customfield_') and isinstance(value, dict) and 'content' in value:
found = True
print(f"[{SCRIPT_NAME}] {key}: {json.dumps(value, indent=2)}", file=sys.stderr)
if not found:
print(f"[{SCRIPT_NAME}] None found", file=sys.stderr)
if not release_notes_field or not release_notes_field.get('content'):
def parse_release_notes(response_json):
release_notes_field_name = 'customfield_10309'
try:
fields = response_json.get('fields')
if not fields:
print(f"[{SCRIPT_NAME}] 'fields' is empty or missing in response", file=sys.stderr)
return ''
release_notes = extract_text_from_content(release_notes_field.get('content', []))
release_notes_field = fields.get(release_notes_field_name)
if not release_notes_field:
print(f"[{SCRIPT_NAME}] Release notes field is empty or missing. Field name: {release_notes_field_name}", file=sys.stderr)
log_customfields_with_content(fields)
return ''
content = release_notes_field.get('content', [])
if not content:
print(f"[{SCRIPT_NAME}] Release notes field was found but 'content' is empty or missing in {release_notes_field_name}", file=sys.stderr)
log_customfields_with_content(fields)
return ''
release_notes = extract_text_from_content(content)
return release_notes
except Exception as e:
print(f"Error parsing release notes: {str(e)}", file=sys.stderr)
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
return ''
def main():
@@ -60,7 +85,7 @@ def main():
)
if response.status_code != 200:
print(f"Error fetching Jira issue: {response.status_code}", file=sys.stderr)
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
sys.exit(1)
release_notes = parse_release_notes(response.json())

263
.github/scripts/label-pr.py vendored Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
# Requires Python 3.9+
"""
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
Usage:
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
Arguments:
pr-number: The pull request number
pr-labels: Current PR labels as JSON array string
-a, --add: Add labels without removing existing ones (default)
-r, --replace: Replace all existing labels
-d, --dry-run: Run without actually applying labels
-c, --config: Path to JSON config file (default: .github/label-pr.json)
Examples:
python label-pr.py 1234 '[]'
python label-pr.py 1234 '[{"name":"label1"}]' -a
python label-pr.py 1234 '[{"name":"label1"}]' --replace
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
python label-pr.py 1234 '[]' --config custom-config.json
"""
import argparse
import json
import os
import subprocess
import sys
DEFAULT_MODE = "add"
DEFAULT_CONFIG_PATH = ".github/label-pr.json"
def load_config_json(config_file: str) -> dict:
"""Load configuration from JSON file."""
if not os.path.exists(config_file):
print(f"❌ Config file not found: {config_file}")
sys.exit(1)
try:
with open(config_file, 'r') as f:
config = json.load(f)
print(f"✅ Loaded config from: {config_file}")
valid_config = True
if not config.get("title_patterns"):
print("❌ Missing 'title_patterns' in config file")
valid_config = False
if not config.get("path_patterns"):
print("❌ Missing 'path_patterns' in config file")
valid_config = False
if not valid_config:
print("::error::Invalid label-pr.json config file, exiting...")
sys.exit(1)
return config
except json.JSONDecodeError as e:
print(f"❌ JSON deserialization error in label-pr.json config: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error loading label-pr.json config: {e}")
sys.exit(1)
def gh_get_changed_files(pr_number: str) -> list[str]:
"""Get list of changed files in a pull request."""
try:
result = subprocess.run(
["gh", "pr", "diff", pr_number, "--name-only"],
capture_output=True,
text=True,
check=True
)
changed_files = result.stdout.strip().split("\n")
return list(filter(None, changed_files))
except subprocess.CalledProcessError as e:
print(f"::error::Error getting changed files: {e}")
return []
def gh_get_pr_title(pr_number: str) -> str:
"""Get the title of a pull request."""
try:
result = subprocess.run(
["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"::error::Error getting PR title: {e}")
return ""
def gh_add_labels(pr_number: str, labels: list[str]) -> None:
"""Add labels to a pull request (doesn't remove existing labels)."""
gh_labels = ','.join(labels)
subprocess.run(
["gh", "pr", "edit", pr_number, "--add-label", gh_labels],
check=True
)
def gh_replace_labels(pr_number: str, labels: list[str]) -> None:
"""Replace all labels on a pull request with the specified labels."""
payload = json.dumps({"labels": labels})
subprocess.run(
["gh", "api", "repos/{owner}/{repo}/issues/" + pr_number, "-X", "PATCH", "--silent", "--input", "-"],
input=payload,
text=True,
check=True
)
def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
"""Check changed files against path patterns and return labels to apply."""
if not changed_files:
return []
labels_to_apply = set() # Use set to avoid duplicates
for label, patterns in path_patterns.items():
for file in changed_files:
if any(file.startswith(pattern) for pattern in patterns):
print(f"👀 File '{file}' matches pattern for label '{label}'")
labels_to_apply.add(label)
break
if "app:shared" in labels_to_apply:
labels_to_apply.add("app:password-manager")
labels_to_apply.add("app:authenticator")
labels_to_apply.remove("app:shared")
if not labels_to_apply:
print("::notice::No matching file paths found.")
return list(labels_to_apply)
def label_title(pr_title: str, title_patterns: dict) -> list[str]:
"""Check PR title against patterns and return labels to apply."""
if not pr_title:
return []
labels_to_apply = set()
title_lower = pr_title.lower()
for label, patterns in title_patterns.items():
for pattern in patterns:
# Check for pattern with : or ( suffix (conventional commits format)
if f"{pattern}:" in title_lower or f"{pattern}(" in title_lower:
print(f"📝 Title matches pattern '{pattern}' for label '{label}'")
labels_to_apply.add(label)
break
if not labels_to_apply:
print("::notice::No matching title patterns found.")
return list(labels_to_apply)
def parse_pr_labels(pr_labels_str: str) -> list[str]:
"""Parse PR labels from JSON array string."""
try:
labels = json.loads(pr_labels_str)
if not isinstance(labels, list):
print("::warning::Failed to parse PR labels: not a list")
return []
return [item.get("name") for item in labels if item.get("name")]
except (json.JSONDecodeError, TypeError) as e:
print(f"::error::Error parsing PR labels: {e}")
return []
def get_preserved_labels(pr_labels_str: str) -> list[str]:
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
existing_labels = parse_pr_labels(pr_labels_str)
print(f"🔍 Parsed PR labels: {existing_labels}")
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
if preserved_labels:
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
return preserved_labels
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Label pull requests based on changed file paths and PR title patterns."
)
parser.add_argument(
"pr_number",
help="The pull request number"
)
parser.add_argument(
"pr_labels",
help="Current PR labels (JSON array)"
)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"-a", "--add",
action="store_true",
help="Add labels without removing existing ones (default)"
)
mode_group.add_argument(
"-r", "--replace",
action="store_true",
help="Replace all existing labels"
)
parser.add_argument(
"-d", "--dry-run",
action="store_true",
help="Run without actually applying labels"
)
parser.add_argument(
"-c", "--config",
default=DEFAULT_CONFIG_PATH,
help=f"Path to JSON config file (default: {DEFAULT_CONFIG_PATH})"
)
args, unknown = parser.parse_known_args() # required to handle --dry-run passed as an empty string ("") by the workflow
return args
def main():
args = parse_args()
config = load_config_json(args.config)
LABEL_TITLE_PATTERNS = config["title_patterns"]
LABEL_PATH_PATTERNS = config["path_patterns"]
pr_number = args.pr_number
mode = "replace" if args.replace else "add"
if args.dry_run:
print("🔍 DRY RUN MODE - Labels will not be applied")
print(f"📌 Labeling mode: {mode}")
print(f"🔍 Checking PR #{pr_number}...")
pr_title = gh_get_pr_title(pr_number)
print(f"📋 PR Title: {pr_title}\n")
changed_files = gh_get_changed_files(pr_number)
print("👀 Changed files:\n" + "\n".join(changed_files) + "\n")
filepath_labels = label_filepaths(changed_files, LABEL_PATH_PATTERNS)
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
all_labels = set(filepath_labels + title_labels)
if all_labels:
print("--------------------------------")
labels_str = ', '.join(sorted(all_labels))
if mode == "add":
print(f"::notice::🏷️ Adding labels: {labels_str}")
if not args.dry_run:
gh_add_labels(pr_number, list(all_labels))
else:
preserved_labels = get_preserved_labels(args.pr_labels)
if preserved_labels:
all_labels.update(preserved_labels)
labels_str = ', '.join(sorted(all_labels))
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
if not args.dry_run:
gh_replace_labels(pr_number, list(all_labels))
else:
print("::warning::No matching patterns found, no labels applied.")
print("✅ Done")
if __name__ == "__main__":
main()

23
.github/scripts/set-build-version.sh vendored Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Runs fastlane setBuildVersionInfo and appends Version Name/Number to GITHUB_STEP_SUMMARY.
# Usage: set-build-version.sh <version_code> [version_name] [toml_path]
VERSION_CODE="${1:?Usage: $0 <version_code> [version_name] [toml_path]}"
VERSION_NAME="${2:-}"
TOML_FILE="${3:-gradle/libs.versions.toml}"
bundle exec fastlane setBuildVersionInfo \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
VERSION_NAME=""
regex='appVersionName = "([^"]+)"'
if [[ "$(cat "$TOML_FILE")" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -73,25 +73,31 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Echo distinct ID ${{ github.event.inputs.distinct_id }}
run: echo ${{ github.event.inputs.distinct_id }}
env:
_DISTINCT_ID: ${{ inputs.distinct_id }}
run: echo "${_DISTINCT_ID}"
- name: Check out repository
if: ${{ !inputs.skip_checkout || false }}
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Calculate version name
id: calc-version-name
env:
_VERSION_NAME: ${{ inputs.version_name }}
_PATCH_VERSION: ${{ inputs.patch_version }}
run: |
output() {
local version_name=$1
echo "version_name=$version_name" >> $GITHUB_OUTPUT
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
}
# override version name if provided
if [[ ! -z "${{ inputs.version_name }}" ]]; then
version_name=${{ inputs.version_name }}
if [[ ! -z "${_VERSION_NAME}" ]]; then
version_name=${_VERSION_NAME}
echo "::warning::Override applied: $version_name"
output "$version_name"
exit 0
@@ -102,7 +108,7 @@ jobs:
latest_tag_version=$(git tag -l --sort=-creatordate | grep "$APP_CODENAME" | head -n 1)
if [[ -z "$latest_tag_version" ]]; then
version_name="${current_year}.${current_month}.${{ inputs.patch_version || 0 }}"
version_name="${current_year}.${current_month}.${_PATCH_VERSION:-0}"
echo "::warning::No tags found, did you checkout? Calculating version from current date: $version_name"
output "$version_name"
exit 0
@@ -111,14 +117,14 @@ jobs:
# Git tag was found, calculate version from latest tag
latest_version=${latest_tag_version:1} # remove 'v' from tag version
latest_major_version=$(echo $latest_version | cut -d "." -f 1)
latest_minor_version=$(echo $latest_version | cut -d "." -f 2)
latest_major_version=$(echo "$latest_version" | cut -d "." -f 1)
latest_minor_version=$(echo "$latest_version" | cut -d "." -f 2)
patch_version=0
if [[ ! -z "${{ inputs.patch_version }}" ]]; then
patch_version=${{ inputs.patch_version }}
if [[ ! -z "${_PATCH_VERSION}" ]]; then
patch_version=${_PATCH_VERSION}
echo "::warning::Patch Version Override applied: $patch_version"
elif [[ "$current_year" == "$latest_major_version" && "$current_month" == "$latest_minor_version" ]]; then
latest_patch_version=$(echo $latest_version | cut -d "." -f 3)
latest_patch_version=$(echo "$latest_version" | cut -d "." -f 3)
patch_version=$(($latest_patch_version + 1))
fi
@@ -127,33 +133,41 @@ jobs:
- name: Calculate version number
id: calc-version-number
env:
_VERSION_NUMBER: ${{ inputs.version_number }}
run: |
# override version number if provided
if [[ ! -z "${{ inputs.version_number }}" ]]; then
version_number=${{ inputs.version_number }}
if [[ ! -z "${_VERSION_NUMBER}" ]]; then
version_number=${_VERSION_NUMBER}
echo "::warning::Override applied: $version_number"
echo "version_number=$version_number" >> $GITHUB_OUTPUT
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
exit 0
fi
version_number=$(($GITHUB_RUN_NUMBER + ${{ env.BASE_VERSION_NUMBER }}))
echo "version_number=$version_number" >> $GITHUB_OUTPUT
version_number=$(($GITHUB_RUN_NUMBER + ${BASE_VERSION_NUMBER}))
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
- name: Create version info JSON
env:
_VERSION_NUMBER: ${{ steps.calc-version-number.outputs.version_number }}
_VERSION_NAME: ${{ steps.calc-version-name.outputs.version_name }}
run: |
json='{
"version_number": "${{ steps.calc-version-number.outputs.version_number }}",
"version_name": "${{ steps.calc-version-name.outputs.version_name }}"
}'
json=$(cat <<EOF
{
"version_number": "${_VERSION_NUMBER}",
"version_name": "${_VERSION_NAME}"
}
EOF
)
echo "$json" > version_info.json
echo "## version-info.json" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo "$json" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "## version-info.json" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"
echo "$json" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: version-info
path: version_info.json

View File

@@ -21,22 +21,22 @@ on:
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
default: false
default: true
type: boolean
publish-to-play-store:
description: "Optional. Deploy bundle artifact to Google Play Store"
required: false
default: false
default: true
type: boolean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
permissions:
contents: read
packages: read
id-token: write
jobs:
version:
@@ -48,82 +48,14 @@ jobs:
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
secrets: inherit
build:
name: Build Authenticator
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Check Authenticator
run: bundle exec fastlane check
- name: Build Authenticator
run: bundle exec fastlane buildAuthenticatorDebug
publish_playstore:
name: Publish Authenticator Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
strategy:
fail-fast: false
matrix:
@@ -131,21 +63,10 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -181,7 +102,7 @@ jobs:
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -192,7 +113,7 @@ jobs:
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
- name: Download Play Store credentials
if: ${{ inputs.publish-to-play-store }}
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -205,40 +126,15 @@ jobs:
- name: AZ Logout
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Verify Play Store credentials
if: ${{ inputs.publish-to-play-store }}
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane run validate_play_store_json_key \
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
@@ -250,22 +146,9 @@ jobs:
- name: Increment version
env:
DEFAULT_VERSION_CODE: ${{ github.run_number }}
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
bundle exec fastlane setBuildVersionInfo \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME_INPUT"
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'aab' }}
@@ -291,17 +174,17 @@ jobs:
keyAlias:"bitwardenauthenticator" \
keyPassword:"$KEY_PASSWORD"
- name: Upload release Play Store .aab artifact
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.aab
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
if-no-files-found: error
- name: Upload release .apk artifact
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -319,38 +202,36 @@ jobs:
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
> ./authenticator-android-apk-sha256.txt
- name: Upload .apk SHA file for release
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-apk-sha256.txt
path: ./authenticator-android-apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-aab-sha256.txt
path: ./authenticator-android-aab-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release bundle to Firebase
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - prod.aab
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
env:
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
run: |
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
# bundles
- name: Publish release bundle to Google Play Store
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
- name: Publish to Play Store - prod.aab
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
env:
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
run: |

84
.github/workflows/build-testharness.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Build Test Harness
on:
push:
paths:
- testharness/**
workflow_dispatch:
inputs:
version-name:
description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project."
required: false
type: string
version-code:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
jobs:
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwpm"
base_version_number: 0
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
build:
name: Build Test Harness
runs-on: ubuntu-24.04
needs: version
steps:
- name: Log inputs to job summary
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
- name: Build Test Harness Debug APK
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.testharness.dev-debug.apk
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
if-no-files-found: error
- name: Create checksum for Test Harness APK
run: |
sha256sum "testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk" \
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.testharness.dev.apk-sha256.txt
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
if-no-files-found: error

View File

@@ -31,13 +31,13 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
permissions:
contents: read
packages: read
id-token: write
jobs:
version:
@@ -50,89 +50,14 @@ jobs:
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
secrets: inherit
build:
name: Build
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Check
run: bundle exec fastlane check
- name: Build
run: bundle exec fastlane assembleDebugApks
- name: Upload test reports on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
publish_playstore:
name: Publish Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
strategy:
fail-fast: false
matrix:
@@ -140,21 +65,10 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -194,7 +108,7 @@ jobs:
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
- name: Download Firebase credentials
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -207,33 +121,8 @@ jobs:
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Update app CI Build info
run: |
@@ -246,13 +135,9 @@ jobs:
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
@@ -305,42 +190,42 @@ jobs:
run: |
bundle exec fastlane assembleDebugApks
- name: Upload release Play Store .aab artifact
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
if-no-files-found: error
- name: Upload beta Play Store .aab artifact
- name: Upload to GitHub Artifacts - beta.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
if-no-files-found: error
- name: Upload release .apk artifact
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
if-no-files-found: error
- name: Upload beta .apk artifact
- name: Upload to GitHub Artifacts - beta.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload debug .apk artifact
- name: Upload to GitHub Artifacts - dev.apk
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
@@ -376,52 +261,52 @@ jobs:
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for debug
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release artifacts to Firebase
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - prod.apk
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
@@ -429,8 +314,8 @@ jobs:
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Publish beta artifacts to Firebase
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - beta.apk
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
@@ -439,12 +324,12 @@ jobs:
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Verify Play Store credentials
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane run validate_play_store_json_key
- name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
- name: Publish to Play Store - prod.aab
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
@@ -453,25 +338,15 @@ jobs:
name: Publish F-Droid artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -497,7 +372,7 @@ jobs:
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -510,33 +385,8 @@ jobs:
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Update app CI Build info
run: |
@@ -549,20 +399,9 @@ jobs:
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
- name: Generate F-Droid artifacts
env:
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
@@ -584,8 +423,8 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
@@ -596,15 +435,15 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
@@ -615,19 +454,19 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release F-Droid artifacts to Firebase
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
- name: Distribute to Firebase - fdroid.apk
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
run: |

View File

@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
on:
schedule:
# Run weekly on Monday at 00:00 UTC
- cron: "0 0 * * 1"
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch:
env:
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: true
@@ -96,4 +96,4 @@ jobs:
--base main \
--head "$BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci"
--label "t:deps"

View File

@@ -4,19 +4,21 @@ run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 5"
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
permissions: {}
jobs:
crowdin-sync:
name: Crowdin Pull - ${{ github.event_name }}
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
contents: read
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -50,6 +52,8 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for creating and pushing a new branch
permission-pull-requests: write # for creating pull request
- name: Download translations
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
@@ -69,5 +73,6 @@ jobs:
create_pull_request: true
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
pull_request_labels: "automated-pr, t:misc"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true
@@ -183,11 +183,15 @@ jobs:
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
run: |
echo "Getting product release notes"
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN")
echo "Getting product release notes..."
# capture output and exit code so this step continues even if we can't retrieve release notes.
script_exit_code=0
product_release_notes=$(python .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
echo "--------------------------------"
if [[ -z "$product_release_notes" || $product_release_notes == "Error checking"* ]]; then
echo "::warning::Failed to fetch release notes from Jira. Output: $product_release_notes"
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
echo "Script Output: $product_release_notes"
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
product_release_notes="<insert product release notes here>"
else
echo "✅ Product release notes:"
@@ -285,5 +289,5 @@ jobs:
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
echo "> [!NOTE]"
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -18,6 +18,7 @@ jobs:
workflow_name: "publish-github-release-bwa.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
make_latest: false
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@@ -19,6 +19,7 @@ jobs:
workflow_name: "publish-github-release-bwpm.yml"
credentials_filename: "play_creds.json"
project_type: android
make_latest: true
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit

View File

@@ -68,20 +68,12 @@ jobs:
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
@@ -91,7 +83,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

View File

@@ -22,7 +22,7 @@ jobs:
actions: write
steps:
- name: Check out repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
permissions: {}
@@ -15,6 +15,7 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: read
id-token: write
pull-requests: write

View File

@@ -0,0 +1,64 @@
name: SDLC / Enforce PR labels
run-name: Enforce labels for PR ${{ github.event.pull_request.number }}
on:
pull_request:
types: [labeled, unlabeled, opened, reopened, edited, synchronize]
permissions: {}
jobs:
enforce-label:
name: Enforce Label
runs-on: ubuntu-24.04
permissions:
pull-requests: read
steps:
- name: Enforce banned labels (e.g. hold, needs-qa)
env:
_HOLD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'hold') }}
_NEEDS_QA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-qa') }}
run: |
if [ "$_HOLD_LABEL" = "true" ]; then
echo "::error::PR has banned label: hold"
exit 1
fi
if [ "$_NEEDS_QA_LABEL" = "true" ]; then
echo "::error::PR has banned label: needs-qa"
exit 1
fi
echo "✅ No banned labels found."
- name: Enforce exactly one Change Type (t:*) label
env:
_PR_ACTION: ${{ github.event.action }}
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
_REPO: ${{ github.repository }}
_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: |
if [ "$_PR_ACTION" = "opened" ] || [ "$_PR_ACTION" = "reopened" ]; then
echo "⏳ Waiting 15s for labeler to run..."
sleep 15
_PR_LABELS=$(gh api "repos/$_REPO/pulls/$_PR_NUMBER" --jq '.labels')
echo "Labels fetched from PR: $_PR_LABELS"
fi
_IGNORE_FOR_RELEASE_LABEL=$(echo "$_PR_LABELS" | jq 'any(.[]; .name == "ignore-for-release")')
if [ "$_IGNORE_FOR_RELEASE_LABEL" = "true" ]; then
echo "⏭️ Skipping type label check - 'ignore-for-release' label present"
exit 0
fi
_T_LABEL_COUNT=$(echo "$_PR_LABELS" | jq '[.[] | select(.name | startswith("t:"))] | length')
case "$_T_LABEL_COUNT" in
1)
echo "✅ PR has exactly one Change Type (t:*) label"
;;
0)
echo "::error::PR is missing a Change Type (t:*) label. PRs must have exactly one Change Type (t:*) label"
exit 1
;;
*)
echo "::error::PR has $_T_LABEL_COUNT Change Type (t:*) labels. PRs must have exactly one Change Type (t:*) label"
exit 1
;;
esac

View File

@@ -0,0 +1,37 @@
name: SDLC / Update Linked Issues on Release
run-name: ${{ inputs.dry-run && '(Dry Run) ' || '' }}Update Linked Issues on Release - ${{ github.event.release.name || inputs.release_url }}
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_url:
description: 'Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
required: true
dry-run:
description: 'Dry run'
type: boolean
default: false
permissions:
contents: read
issues: write
jobs:
update-linked-issues:
name: Update Linked Issues
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Update Linked Issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }}
_DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
run: |
python3 .github/scripts/gh_release_update_issues.py "$_RELEASE_URL" $_DRY_RUN

90
.github/workflows/sdlc-label-pr.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: SDLC / Label PR
run-name: Label PR ${{ github.event.pull_request.number || inputs.pr-number }}${{ github.event_name == 'workflow_dispatch' && format(' / mode "{0}" dry-run "{1}"', inputs.mode, inputs.dry-run) || '' }}
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
inputs:
pr-number:
description: "Pull Request Number"
required: true
type: number
mode:
description: "Labeling Mode"
type: choice
options:
- add
- replace
default: add
dry-run:
description: "Dry Run - Don't apply labels"
type: boolean
default: false
env:
_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
jobs:
label-pr:
name: Label PR by Changed Files
runs-on: ubuntu-24.04
permissions:
pull-requests: write # required to update labels
contents: read
steps:
- name: Check out repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Determine label mode for Pull Request
id: label-mode
env:
GH_TOKEN: ${{ github.token }}
_PR_USER: ${{ github.event.pull_request.user.login }}
_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: |
# Support workflow_dispatch testing by retrieving PR data
if [ -z "$_PR_USER" ]; then
echo "👀 PR User is empty, retrieving PR data for PR #$_PR_NUMBER..."
PR_DATA=$(gh pr view "$_PR_NUMBER" --json author,isCrossRepository)
_PR_USER=$(echo "$PR_DATA" | jq -r '.author.login')
_IS_FORK=$(echo "$PR_DATA" | jq -r '.isCrossRepository')
fi
echo "📋 PR User: $_PR_USER"
echo "📋 Is Fork: $_IS_FORK"
# Handle PRs with labels set by other automations by adding instead of replacing
if [ "$_IS_FORK" = "true" ]; then
echo "➡️ Fork PR ($_PR_USER). Label mode: --add"
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "➡️ Normal PR. Label mode: --replace"
echo "label_mode=--replace" >> "$GITHUB_OUTPUT"
- name: Label PR based on changed files
env:
GH_TOKEN: ${{ github.token }}
_LABEL_MODE: ${{ inputs.mode && format('--{0}', inputs.mode) || steps.label-mode.outputs.label_mode }}
_DRY_RUN: ${{ inputs.dry-run == true && '--dry-run' || '' }}
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
run: |
if [ -z "$_PR_LABELS" ] || [ "$_PR_LABELS" = "null" ] || [ "$_PR_LABELS" = "[]" ]; then
echo "🔍 No current PR labels found, retrieving PR data for PR #$_PR_NUMBER..."
_PR_LABELS=$(gh pr view "$_PR_NUMBER" --json labels --jq '.labels')
fi
echo "🔍 Labeling PR #$_PR_NUMBER with mode: \"$_LABEL_MODE\" and dry-run: \"$_DRY_RUN\" and current PR labels: \"$_PR_LABELS\"..."
echo "🐍 Running label-pr.py script..."
echo ""
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_PR_LABELS" "$_LABEL_MODE" "$_DRY_RUN"

View File

@@ -63,7 +63,7 @@ jobs:
permission-contents: write
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
@@ -190,7 +190,7 @@ jobs:
--base main \
--head "$_BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci")
--label "t:deps")
echo "## 🚀 Created PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
fi
@@ -204,7 +204,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false

View File

@@ -3,9 +3,8 @@ name: Test
on:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
- main
- release/**/*
pull_request:
types: [opened, synchronize]
merge_group:
@@ -13,105 +12,147 @@ on:
workflow_dispatch:
env:
_JAVA_VERSION: 21
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
jobs:
test:
name: Test
test-sharded:
name: "Test ${{ matrix.group }}"
runs-on: ubuntu-24.04
permissions:
packages: read
pull-requests: write
strategy:
fail-fast: false
matrix:
include:
- group: static-analysis
fastlane_method: checkLint
fastlane_options: ""
# App shards
- group: app-data
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
- group: app-ui-auth-tools
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
- group: app-ui-platform
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
- group: app-ui-vault
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
# Authenticator
- group: authenticator
fastlane_method: testLibraries
fastlane_options: ":authenticator"
# Library shards
- group: lib-core-network-bridge
fastlane_method: testLibraries
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
- group: lib-data-ui
fastlane_method: testLibraries
fastlane_options: ":data :ui"
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env._JAVA_VERSION }}
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Build and test
- name: Run tests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_GROUP: ${{ matrix.group }}
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
run: |
bundle exec fastlane check
if [ "$_GROUP" = "app-ui-auth-tools" ]; then
_TOP_LEVEL_TESTS=$(basename -a -s .kt app/src/test/kotlin/com/x8bit/bitwarden/*Test.kt \
| xargs -I{} printf ' --tests com.x8bit.bitwarden.{}')
_FASTLANE_OPTIONS="${_FASTLANE_OPTIONS} ${_TOP_LEVEL_TESTS}"
fi
- name: Upload test reports
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: test-reports
path: |
build/reports/kover/reportMergedCoverage.xml
app/build/reports/tests/
authenticator/build/reports/tests/
authenticatorbridge/build/reports/tests/
core/build/reports/tests/
data/build/reports/tests/
network/build/reports/tests/
ui/build/reports/tests/
if [ "$_GROUP" = "static-analysis" ]; then
bundle exec fastlane "$_FASTLANE_METHOD"
else
bundle exec fastlane "$_FASTLANE_METHOD" target:"$_FASTLANE_OPTIONS"
fi
- name: Generate coverage report
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bundle exec fastlane generateCoverageReport
- name: Upload to codecov.io
id: upload-to-codecov
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:
os: linux
files: build/reports/kover/reportMergedCoverage.xml
flags: ${{ matrix.group }}
fail_ci_if_error: true
disable_search: true
- name: Comment PR if tests failed
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: test-reports-${{ matrix.group }}
path: |
**/build/reports/tests/
app/build/reports/lint-results-*.html
app/build/reports/detekt/
if-no-files-found: warn
coverage-notify:
name: Coverage Notification
runs-on: ubuntu-24.04
needs: test-sharded
if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'pull_request')
permissions:
pull-requests: write
steps:
- name: Notify Codecov that all uploads are complete
id: codecov-notify
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
continue-on-error: true
with:
run_command: send-notifications
- name: Comment PR if coverage notification failed
if: steps.codecov-notify.outcome == 'failure'
env:
PR_NUMBER: ${{ github.event.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ACTOR: ${{ github.triggering_actor }}
run: |
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
echo "> Uploading code coverage report failed. Please check the \"Notify Codecov\" step for more details." >> "$GITHUB_STEP_SUMMARY"
if [ -n "$PR_NUMBER" ]; then
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Coverage Notification" step of [Test]('$_GITHUB_ACTION_RUN_URL') for more details.'
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
fi
test:
name: Test
runs-on: ubuntu-24.04
permissions: {}
needs: test-sharded
if: always()
steps:
- name: Ensure sharded tests passed
env:
TESTS_RESULT: ${{ needs.test-sharded.result }}
run: |
if [ "$TESTS_RESULT" != "success" ]; then
echo "❌ Tests failed"
exit 1
fi
echo "✅ All tests passed!"

3
.gitignore vendored
View File

@@ -36,6 +36,9 @@ user.properties
/app/src/standardRelease/google-services.json
/authenticator/src/google-services.json
# Claude Code outputs
.claude/outputs/
# Python
.python-version
__pycache__/

View File

@@ -1 +0,0 @@
npx lint-staged

View File

@@ -14,5 +14,8 @@ gem 'logger'
gem 'mutex_m'
gem 'csv'
# Since ruby 3.4.1 these are not included in the standard library
gem 'nkf'
# Starting with Ruby 3.5.0, these are not included in the standard library
gem 'ostruct'

View File

@@ -1,18 +1,15 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1177.0)
aws-sdk-core (3.235.0)
aws-partitions (1.1226.0)
aws-sdk-core (3.243.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,25 +17,25 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.115.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.201.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.216.0)
aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
date (3.4.1)
date (3.5.1)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -46,7 +43,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -58,25 +55,26 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastimage (2.4.1)
fastlane (2.229.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
@@ -84,6 +82,7 @@ GEM
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -103,6 +102,7 @@ GEM
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
@@ -169,29 +169,29 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.15.2)
json (2.19.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.17.0)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
public_suffix (7.0.5)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -209,7 +209,7 @@ GEM
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
time (0.4.1)
time (0.4.2)
date
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
@@ -241,6 +241,7 @@ DEPENDENCIES
fastlane-plugin-firebase_app_distribution
logger
mutex_m
nkf
ostruct
time
@@ -248,4 +249,4 @@ RUBY VERSION
ruby 3.4.2p28
BUNDLED WITH
2.6.9
2.6.2

View File

@@ -1,17 +1,20 @@
import com.android.build.api.dsl.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
configure<LibraryExtension> {
namespace = "com.bitwarden.annotation"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
@@ -37,6 +40,6 @@ android {
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
}
}

View File

21
annotation/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,9 +1,10 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.impl.VariantOutputImpl
import com.android.utils.cxx.io.removeExtensionIfPresent
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
import com.google.gms.googleservices.GoogleServicesTask
import dagger.hilt.android.plugin.util.capitalize
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
@@ -15,7 +16,6 @@ plugins {
// standardDebug builds in the merged manifest.
alias(libs.plugins.crashlytics)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
@@ -43,27 +43,35 @@ val ciProperties = Properties().apply {
}
}
android {
namespace = "com.x8bit.bitwarden"
compileSdk = libs.versions.compileSdk.get().toInt()
base {
// Set the base archive name for publishing purposes. This is used to derive the
// APK and AAB artifact names when uploading to Firebase and Play Store.
archivesName.set("com.x8bit.bitwarden")
}
room {
schemaDirectory("$projectDir/schemas")
room {
schemaDirectory("$projectDir/schemas")
}
configure<ApplicationExtension> {
namespace = "com.x8bit.bitwarden"
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
applicationId = "com.x8bit.bitwarden"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
minSdk {
version = release(libs.versions.minSdk.get().toInt())
}
targetSdk {
version = release(libs.versions.targetSdk.get().toInt())
}
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Set the base archive name for publishing purposes. This is used to derive the APK and AAB
// artifact names when uploading to Firebase and Play Store.
base.archivesName = "com.x8bit.bitwarden"
buildConfigField(
type = "String",
name = "CI_INFO",
@@ -141,39 +149,6 @@ android {
}
}
applicationVariants.all {
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
outputs
.mapNotNull { it as? BaseVariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> "$applicationId"
else -> output.outputFileName.removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName = "$fileNameWithoutExtension.apk"
val variantName = name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
tasks
.getByName("bundle${variantName.capitalize()}")
.finalizedBy(renameTaskName)
}
}
compileOptions {
sourceCompatibility(libs.versions.jvmTarget.get())
targetCompatibility(libs.versions.jvmTarget.get())
@@ -200,9 +175,50 @@ android {
}
}
androidComponents {
onVariants { appVariant ->
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
val applicationId = appVariant.applicationId.get()
val flavorName = appVariant.flavorName
val variantName = appVariant.name
val buildType = appVariant.buildType
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> applicationId
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName.set("$fileNameWithoutExtension.apk")
val renameTaskName = "rename${variantName.uppercaseFirstChar()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
val namespace = appVariant.namespace.get()
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
val bundleTaskName = "bundle${variantName.uppercaseFirstChar()}"
tasks
.named { it == bundleTaskName }
.configureEach { finalizedBy(renameTaskName) }
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
}
}
@@ -210,7 +226,7 @@ configurations.all {
resolutionStrategy.dependencySubstitution {
if ((userProperties["localSdk"] as String?).toBoolean()) {
substitute(module("com.bitwarden:sdk-android"))
.using(module("com.bitwarden:sdk-android:LOCAL"))
.using(module("com.bitwarden:sdk-android.dev:LOCAL"))
}
}
}
@@ -220,7 +236,7 @@ dependencies {
add("standardImplementation", dependencyNotation)
}
implementation(files("libs/authenticatorbridge-1.0.1-release.aar"))
implementation(project(":authenticatorbridge"))
implementation(project(":annotation"))
implementation(project(":core"))
@@ -235,6 +251,7 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.biometrics)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3)
@@ -259,6 +276,8 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.bumptech.glide.okhttp)
ksp(libs.bumptech.glide.compiler)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
@@ -278,9 +297,11 @@ dependencies {
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.billing)
standardImplementation(libs.google.play.review)
// Pull in test fixtures from other modules
testImplementation(testFixtures(project(":core")))
testImplementation(testFixtures(project(":data")))
testImplementation(testFixtures(project(":network")))
testImplementation(testFixtures(project(":ui")))
@@ -297,18 +318,6 @@ dependencies {
testImplementation(libs.square.turbine)
}
tasks {
withType<Test> {
useJUnitPlatform()
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
}
afterEvaluate {
// Disable Fdroid-specific tasks that we want to exclude
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +

View File

@@ -0,0 +1,70 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "2835802f9de260f6f5109c81081e9b46",
"entities": [
{
"tableName": "organization_events",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL, `organization_id` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationEventType",
"columnName": "organization_event_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherId",
"columnName": "cipher_id",
"affinity": "TEXT"
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_organization_events_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2835802f9de260f6f5109c81081e9b46')"
]
}
}

View File

@@ -0,0 +1,279 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "61353072161e3101ade140e2c4b65495",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasTotp",
"columnName": "has_totp",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
},
{
"name": "index_ciphers_user_id_organization_id",
"unique": false,
"columnNames": [
"user_id",
"organization_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
}
]
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canManage",
"columnName": "manage",
"affinity": "INTEGER"
},
{
"fieldPath": "defaultUserCollectionEmail",
"columnName": "default_user_collection_email",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'0'"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
]
}
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider>
<capabilities>
<capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" />
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" />
</capabilities>
</credential-provider>

View File

@@ -0,0 +1,44 @@
Recognized as best password manager by PCMag, WIRED, The Verge, CNET, G2, and more!
SECURE YOUR DIGITAL LIFE
Secure your digital life and protect against data breaches by generating and saving unique, strong passwords for every account. Maintain everything in an end-to-end encrypted password vault that only you can access.
ACCESS YOUR DATA, ANYWHERE, ANYTIME, ON ANY DEVICE
Easily manage, store, secure, and share unlimited passwords and passkeys across unlimited devices without restrictions.
USE PASSKEYS WHEREVER YOU LOG IN
Create, store, and sync passkeys across the Bitwarden mobile app and browser extensions for a secure, passwordless experience no matter what device you're on.
EVERYONE SHOULD HAVE THE TOOLS TO STAY SAFE ONLINE
Utilize Bitwarden for free with no ads or selling data. Bitwarden believes everyone should have the ability to stay safe online. Premium plans offer access to advanced features.
EMPOWER YOUR TEAMS WITH BITWARDEN
Plans for Teams and Enterprise come with professional business features. Some examples include SSO integration, self-hosting, directory integration and SCIM provisioning, global policies, API access, event logs, and more.
Use Bitwarden to secure your workforce and share sensitive information with colleagues.
More reasons to choose Bitwarden:
World-Class Encryption
Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashing, and PBKDF2 SHA-256) so your data stays secure and private.
3rd-party Audits
Bitwarden regularly conducts comprehensive third-party security audits with notable security firms. These annual audits include source code assessments and penetration testing across Bitwarden IPs, servers, and web applications.
Advanced 2FA
Secure your login with a third-party authenticator, emailed codes, or FIDO2 WebAuthn credentials such as a hardware security key or passkey.
Bitwarden Send
Transmit data directly to others while maintaining end-to-end encrypted security and limiting exposure.
Built-in Generator
Create long, complex, and distinct passwords and unique usernames for every site you visit. Integrate with email alias providers for additional privacy.
Global Translations
Bitwarden translations exist for more than 50 languages.
Cross-Platform Applications
Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
Accessibility Services Disclosure: Bitwarden offers the ability to use the Accessibility Service to augment Autofill on older devices or in cases where autofill is not working correctly. When enabled, the Accessibility Service is used to search for login fields in apps and websites. This establishes the appropriate field IDs when a match for the app or site is found and inserts credentials. When the Accessibility Service is active Bitwarden does not store information or control any on-screen elements beyond inserting credentials.

View File

@@ -0,0 +1 @@
Bitwarden is a login and password manager that helps keep you safe while online.

View File

@@ -0,0 +1 @@
Bitwarden Password Manager

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.billing.manager
import android.content.Context
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
* F-Droid users are eligible for the Premium upgrade flow.
*/
@OmitFromCoverage
@Suppress("UnusedParameter")
class PlayBillingManagerImpl(
context: Context,
dispatcherManager: DispatcherManager,
) : PlayBillingManager {
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
MutableStateFlow(true)
}

View File

@@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
/**
* F-Droid implementation of [GmsManager]. Always returns `false` since GMS is not available.
*/
@Suppress("UnusedParameter")
class GmsManagerImpl(
context: Context,
) : GmsManager {
override fun isVersionAtLeast(version: Int): Boolean = false
}

View File

@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="horizons.permission.HEADSET_CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />
@@ -83,14 +84,6 @@
<data android:host="*.bitwarden.eu" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -118,6 +111,35 @@
</intent-filter>
</activity>
<!-- Credential Provider Activity for handling passkey and password credential operations.
This activity is NOT exported to protect against external apps attempting to extract
vault credentials by sending malicious intents. Only our own PendingIntents can
launch this activity.
This is a transparent trampoline activity that launches MainActivity for credential
operations and forwards results back to the Credential Manager framework.
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
must remain in the back stack to receive the ActivityResult callback from
MainActivity. -->
<activity
android:name=".CredentialProviderActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".AccessibilityActivity"
android:exported="false"
@@ -138,6 +160,19 @@
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -168,6 +203,16 @@
android:host="webauthn-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sso-cookie-vendor"
android:scheme="bitwarden" />
</intent-filter>
</activity>
<provider
@@ -260,7 +305,7 @@
android:name="com.x8bit.bitwarden.AutofillTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/autofill_title"
android:label="@string/autofill_verb"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>

View File

@@ -1,5 +1,33 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.iode.firefox",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "eu.weblibre.gecko",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "BB:2A:97:F5:61:53:35:C9:E5:7C:86:6F:1C:30:ED:4F:D7:D7:BD:DC:BC:BC:06:68:FE:93:A5:79:17:3D:3D:2D"
},
{
"build": "release",
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
}
]
}
},
{
"type": "android",
"info": {
@@ -12,18 +40,6 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "org.chromium.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A8:56:48:50:79:BC:B3:57:BF:BE:69:BA:19:A9:BA:43:CD:0A:D9:AB:22:67:52:C7:80:B6:88:8A:FD:48:21:6B"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -815,6 +815,38 @@
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.zoho.primeum.stable",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.amazon.cloud9",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"
},
{
"build": "release",
"cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"
}
]
}
}
]
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
@@ -28,6 +29,7 @@ class AuthCallbackViewModel @Inject constructor(
val webAuthResult = action.intent.getWebAuthResultOrNull()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
when {
yubiKeyResult != null -> {
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
@@ -45,6 +47,12 @@ class AuthCallbackViewModel @Inject constructor(
)
}
cookieCallbackResult != null -> {
authRepository.setCookieCallbackResult(
result = cookieCallbackResult,
)
}
webAuthResult != null -> {
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
}

View File

@@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.HiltAndroidApp
@@ -24,9 +23,6 @@ class BitwardenApplication : Application() {
@Inject
lateinit var logsManager: LogsManager
@Inject
lateinit var networkConnectionManager: NetworkConnectionManager
@Inject
lateinit var networkConfigManager: NetworkConfigManager

View File

@@ -0,0 +1,89 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.credentials.BitwardenCredentialProviderService
import dagger.hilt.android.AndroidEntryPoint
/**
* Transparent trampoline activity for handling credential provider operations.
*
* This activity is declared as `exported="false"` in the manifest to ensure only
* our own PendingIntents can launch it. This protects against external apps attempting
* to extract vault credentials by sending malicious intents via CredentialManager.
*
* All credential flows (FIDO2 passkeys, password credentials) are routed through this
* activity when triggered by the Android CredentialManager framework via our
* [BitwardenCredentialProviderService].
*
* ## Architecture
*
* This activity does not host any UI itself. It acts as a trampoline that:
* 1. Receives the credential intent from the CredentialManager framework
* 2. Sets the pending credential request via [CredentialProviderViewModel], which stores
* it in `CredentialProviderRequestManager` for secure relay to [MainViewModel]
* 3. Launches [MainActivity] to handle the actual credential UI
* 4. Forwards the result back to the CredentialManager framework
*
* This preserves the single-Activity architecture where all UI is hosted by MainActivity,
* while still allowing the CredentialManager framework to receive results properly.
*/
@OmitFromCoverage
@AndroidEntryPoint
class CredentialProviderActivity : ComponentActivity() {
private val viewModel: CredentialProviderViewModel by viewModels()
/**
* Launcher for MainActivity that forwards the result back to Credential Manager.
*/
private val mainActivityLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
// Forward result back to Credential Manager framework
setResult(result.resultCode, result.data)
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
// Process credential intent (sets pending request on CredentialProviderRequestManager)
viewModel.trySendAction(CredentialProviderAction.ReceiveFirstIntent(intent))
launchMainActivityForResult()
}
// On restoration (process death), result comes via mainActivityLauncher callback
}
private fun launchMainActivityForResult() {
val mainIntent = Intent(this, MainActivity::class.java).apply {
// Pending credential request is retrieved by MainViewModel from
// CredentialProviderRequestManager, triggering appropriate navigation.
// CredentialProviderCompletionManager handles setResult/finish.
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
mainActivityLauncher.launch(mainIntent)
}
override fun onNewIntent(intent: Intent) {
val newIntent = intent.validate()
super.onNewIntent(newIntent)
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
launchMainActivityForResult()
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
val newIntent = intent.validate()
super.onNewIntent(newIntent, caller)
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
launchMainActivityForResult()
}
}

View File

@@ -0,0 +1,110 @@
package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* A view model that handles credential provider operations for [CredentialProviderActivity].
*
* This ViewModel processes credential-related intents and sets the pending credential request
* on [CredentialProviderRequestManager] for relay to [MainViewModel]. This ensures credential
* data is never passed through intent extras to exported activities, providing security
* hardening against malicious intent attacks.
*
* Since [CredentialProviderActivity] is a transparent trampoline with no UI, this ViewModel only
* handles intent processing. All UI state management (theme, feature flags, auth flows) is
* handled by [MainActivity].
*
* @see RootNavViewModel for navigation based on SpecialCircumstance.
*/
@HiltViewModel
class CredentialProviderViewModel @Inject constructor(
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val authRepository: AuthRepository,
private val bitwardenCredentialManager: BitwardenCredentialManager,
) : BaseViewModel<Unit, Unit, CredentialProviderAction>(initialState = Unit) {
override fun handleAction(action: CredentialProviderAction) {
when (action) {
is CredentialProviderAction.ReceiveFirstIntent -> handleIntent(action.intent)
is CredentialProviderAction.ReceiveNewIntent -> handleIntent(action.intent)
}
}
private fun handleIntent(intent: Intent) {
intent.getCreateCredentialRequestOrNull()?.let { handleCreateCredential(it) }
?: intent.getFido2AssertionRequestOrNull()?.let { handleFido2Assertion(it) }
?: intent.getProviderGetPasswordRequestOrNull()?.let { handlePasswordGet(it) }
?: intent.getGetCredentialsRequestOrNull()?.let { handleGetCredentials(it) }
}
private fun handleCreateCredential(request: CreateCredentialRequest) {
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
// Switch accounts if the selected user is not the active user
if (authRepository.activeUserId != null &&
authRepository.activeUserId != request.userId
) {
authRepository.switchAccount(request.userId)
}
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.CreateCredential(request),
)
}
private fun handleFido2Assertion(request: Fido2CredentialAssertionRequest) {
// Set the user's verification status when a new FIDO 2 request is received
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.Fido2Assertion(request),
)
}
private fun handlePasswordGet(request: ProviderGetPasswordCredentialRequest) {
// Set the user's verification status when a new GetPassword request is received
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.GetPassword(request),
)
}
private fun handleGetCredentials(request: GetCredentialsRequest) {
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.GetCredentials(request),
)
}
}
/**
* Models actions for the [CredentialProviderViewModel].
*/
sealed class CredentialProviderAction {
/**
* Receive the first intent when the activity is created.
*/
data class ReceiveFirstIntent(val intent: Intent) : CredentialProviderAction()
/**
* Receive a new intent when the activity receives onNewIntent.
*/
data class ReceiveNewIntent(val intent: Intent) : CredentialProviderAction()
}

View File

@@ -12,9 +12,11 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -33,10 +35,12 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.ROOT_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
@@ -80,6 +84,10 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.WebAuthnResult(it))
}
private val cookieLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -106,6 +114,7 @@ class MainActivity : AppCompatActivity() {
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
),
) {
ObserveScreenDataEffect(
@@ -119,16 +128,23 @@ class MainActivity : AppCompatActivity() {
) {
NavHost(
navController = navController,
startDestination = ROOT_ROUTE,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Nothing else should end up at this top level, we just want the ability
// to have the debug menu appear on top of the rest of the app without
// interacting with the state-based navigation used by the RootNavScreen.
// Root navigation, debug menu, and cookie acquisition exist at
// this top level. They can appear on top of the rest of the app
// without interacting with the state-based navigation used by
// RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -202,6 +218,8 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),

View File

@@ -11,11 +11,13 @@ import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.share.ShareManager
import com.bitwarden.ui.platform.model.TotpData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult
@@ -25,12 +27,10 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
@@ -46,7 +46,7 @@ import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
@@ -54,6 +54,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -77,10 +78,11 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val shareManager: ShareManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
@@ -164,6 +166,13 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
@@ -188,6 +197,7 @@ class MainViewModel @Inject constructor(
is MainAction.DuoResult -> handleDuoResult(action)
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -209,6 +219,7 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
}
}
@@ -230,6 +241,12 @@ class MainViewModel @Inject constructor(
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
}
private fun handleCookieAcquisitionResult(action: MainAction.CookieAcquisitionResult) {
authRepository.setCookieCallbackResult(
result = action.cookieCallbackResult.getCookieCallbackResult(),
)
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -273,6 +290,10 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
private fun handleCookieAcquisitionReady() {
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
handleIntent(
intent = action.intent,
@@ -313,12 +334,11 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val hasPremiumCheckoutCallback = intent.isPremiumCheckoutCallback
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
credentialProviderRequestManager.getPendingCredentialRequest()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@@ -376,57 +396,9 @@ class MainViewModel @Inject constructor(
)
}
createCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
createCredentialRequest.isUserPreVerified
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = createCredentialRequest,
)
// Switch accounts if the selected user is not the active user.
if (authRepository.activeUserId != null &&
authRepository.activeUserId != createCredentialRequest.userId
) {
authRepository.switchAccount(createCredentialRequest.userId)
}
}
fido2AssertCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
fido2AssertCredentialRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = fido2AssertCredentialRequest,
)
}
providerGetPasswordRequest != null -> {
// Set the user's verification status when a new GetPassword request is
// received to force explicit verification if the user's vault is
// unlocked when the request is received.
bitwardenCredentialManager.isUserVerified =
providerGetPasswordRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = providerGetPasswordRequest,
)
}
getCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = getCredentialsRequest,
)
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
@@ -448,10 +420,52 @@ class MainViewModel @Inject constructor(
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
requestJson = importCredentialsRequest.request.requestJson,
credentialTypes = importCredentialsRequest.request.credentialTypes,
knownExtensions = importCredentialsRequest.request.knownExtensions,
),
)
}
credentialProviderRequest != null -> {
handleCredentialRequest(credentialProviderRequest)
}
}
}
/**
* Handles a credential request relayed from [CredentialProviderActivity] via
* [CredentialProviderRequestManager].
*
* This method converts the [CredentialProviderRequest] into the appropriate
* [SpecialCircumstance] for routing by [RootNavViewModel]. The credential data is trusted
* because it was set by our own [CredentialProviderActivity] through the internal manager,
* not parsed from intent extras.
*/
private fun handleCredentialRequest(request: CredentialProviderRequest) {
specialCircumstanceManager.specialCircumstance = when (request) {
is CredentialProviderRequest.CreateCredential -> {
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = request.request,
)
}
is CredentialProviderRequest.Fido2Assertion -> {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = request.request,
)
}
is CredentialProviderRequest.GetPassword -> {
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = request.request,
)
}
is CredentialProviderRequest.GetCredentials -> {
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = request.request,
)
}
}
}
@@ -534,6 +548,13 @@ sealed class MainAction {
*/
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
/**
* Receive the result from the cookie acquisition flow.
*/
data class CookieAcquisitionResult(
val cookieCallbackResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/
@@ -604,6 +625,12 @@ sealed class MainAction {
data class DynamicColorsUpdate(
val isDynamicColorsEnabled: Boolean,
) : Internal()
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
}
}
@@ -633,6 +660,11 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -124,6 +124,16 @@ interface AuthDiskSource : AppIdProvider {
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/
fun getLocalUserDataKey(userId: String): String?
/**
* Stores the local user data key for a given [userId].
*/
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
/**
* Retrieves a private key using a [userId].
*/
@@ -211,7 +221,7 @@ interface AuthDiskSource : AppIdProvider {
/**
* Gets the flow for the biometrics key for the given [userId].
*/
fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?>
fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?>
/**
* Retrieves a pin-protected user key for the given [userId].

View File

@@ -35,6 +35,7 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
private const val STATE_KEY = "state"
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
@@ -144,10 +145,8 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null)
storeEncryptedPin(userId = userId, encryptedPin = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
storeOrganizationKeys(userId = userId, organizationKeys = null)
@@ -162,10 +161,14 @@ class AuthDiskSourceImpl(
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
storeShowImportLogins(userId = userId, showImportLogins = null)
storeLastLockTimestamp(userId = userId, lastLockTimestamp = null)
storeEncryptedPin(userId = userId, encryptedPin = null)
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null)
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
// Do not remove OnboardingStatus we want to keep track of this even after logout.
// Certain values are never removed as required by the feature requirements:
// * DeviceKey
// * PendingAuthRequest
// * OnboardingStatus
}
override fun getAuthenticatorSyncUnlockKey(userId: String): String? =
@@ -236,6 +239,13 @@ class AuthDiskSourceImpl(
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
@@ -330,7 +340,7 @@ class AuthDiskSourceImpl(
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
}
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
override fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?> =
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
@@ -372,7 +382,10 @@ class AuthDiskSourceImpl(
inMemoryOnly: Boolean,
) {
inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope
if (inMemoryOnly) return
if (inMemoryOnly) {
getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope)
return
}
putString(
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
value = pinProtectedUserKeyEnvelope,

View File

@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.ZonedDateTime
import java.time.Instant
/**
* Represents the current account information for a given user.
@@ -37,12 +37,12 @@ data class AccountJson(
*
* @property userId The ID of the user.
* @property email The user's email address.
* @property isEmailVerified Whether or not the user's email is verified.
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
* @property isEmailVerified Whether the user's email is verified.
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
* @property hasPremium True if the user has a premium account.
* @property hasPremium True if the user has a Premium account.
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property forcePasswordResetReason Describes the reason for a forced password reset.
* @property kdfType The KDF type.
@@ -103,7 +103,7 @@ data class AccountJson(
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime?,
val creationDate: Instant?,
)
/**

View File

@@ -30,7 +30,7 @@ class AuthSdkSourceImpl(
getClient()
.auth()
.newAuthRequest(
email = email,
email = email.lowercase(),
)
}
@@ -42,7 +42,7 @@ class AuthSdkSourceImpl(
.platform()
.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email,
fingerprintMaterial = email.lowercase(),
publicKey = publicKey,
),
)

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.bitwarden.ui.platform.model.TotpData
/**
* Manager for keeping track of requests from the Bitwarden Authenticator app to add a TOTP

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.bitwarden.ui.platform.model.TotpData
/**
* Default in memory implementation for [AddTotpItemFromAuthenticatorManager].

View File

@@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import java.time.Clock
import javax.inject.Singleton
import kotlin.coroutines.coroutineContext
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
@@ -129,7 +128,6 @@ class AuthRequestManagerImpl(
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
clearPendingAuthRequest()
@@ -163,7 +161,7 @@ class AuthRequestManagerImpl(
emit(result)
if (result is AuthRequestUpdatesResult.Error) return@flow
var isComplete = false
while (coroutineContext.isActive && !isComplete) {
while (currentCoroutineContext().isActive && !isComplete) {
delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
val updateResult = result as AuthRequestUpdatesResult.Update
authRequestsService
@@ -200,7 +198,6 @@ class AuthRequestManagerImpl(
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
isComplete = true

View File

@@ -8,8 +8,8 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource

Some files were not shown because too many files have changed in this diff Show More