Compare commits

...

158 Commits

Author SHA1 Message Date
Andre Rosado
c2d4b37a83 added logs on networkconnection manager 2026-01-27 17:41:33 +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
610 changed files with 31537 additions and 6352 deletions

View File

@@ -1,27 +1,3 @@
Use the `reviewing-changes` skill to review this pull request.
The PR branch is already checked out in the current working directory.
## CRITICAL OUTPUT REQUIREMENTS
**Summary Format (REQUIRED):**
- **Clean PRs (no issues)**: 2-3 lines MAXIMUM
- Format: `**Overall Assessment:** APPROVE\n[One sentence]`
- Example: "Clean refactoring following established patterns"
- **PRs with issues**: Verdict + critical issues list (5-10 lines MAX)
- Format: `**Overall Assessment:** APPROVE/REQUEST CHANGES\n**Critical Issues:**\n- issue 1\nSee inline comments`
- All details go in inline comments with `<details>` tags, NOT in summary
**NEVER create:**
- ❌ Praise sections ("Strengths", "Good Practices", "Excellent X")
- ❌ Verbose analysis sections (Architecture Assessment, Technical Review, Code Quality, etc.)
- ❌ Tables, statistics, or detailed breakdowns in summary
- ❌ Multiple summary sections
- ❌ Checkmarks listing everything done correctly
**Inline Comments:**
- Create separate inline comment for each specific issue/recommendation
- Use collapsible `<details>` sections for code examples and explanations
- Only severity + one-line description visible; all other content collapsed
- Track status of previously identified issues if this is a subsequent review

View File

@@ -1,56 +1,34 @@
---
name: reviewing-changes
version: 2.0.0
description: Comprehensive code reviews for Bitwarden Android. Detects change type (dependency update, bug fix, feature, UI, refactoring, infrastructure) and applies appropriate review depth. Validates MVVM patterns, Hilt DI, security requirements, and test coverage per project standards. Use when reviewing pull requests, checking commits, analyzing code changes, or evaluating architectural compliance.
version: 3.0.0
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
---
# Reviewing Changes
# Reviewing Changes - Android Additions
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
## Instructions
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback. This improves accuracy by 40% according to research.
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
### Step 1: Check for Existing Review Threads
Always check for existing comment threads to avoid duplicate comments:
### Step 1: Retrieve Additional Details
<thinking>
Before creating any comments:
1. Is this a fresh review or re-review of the same PR?
2. What existing discussion might already exist?
3. Which findings should update existing threads vs create new?
Determine if more context is available for the changes:
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
2. Are there other GitHub pull requests mentioned in the PR title or body?
</thinking>
**Thread Detection Procedure:**
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
1. **Fetch existing comment count:**
```bash
gh pr view <pr-number> --json comments --jq '.comments | length'
```
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
2. **If count = 0:** No existing threads. Skip to Step 2 (all comments will be new).
3. **If count > 0:** Fetch full comment data to check for existing threads.
```bash
gh pr view <pr-number> --json comments --jq '.comments[] | {id, path, line, body}' > pr_comments.json
```
4. **Parse existing threads:** Extract file paths, line numbers, and issue summaries from previous review comments.
- Build map: `{file:line → {comment_id, issue_summary, resolved}}`
- Note which issues already have active discussions
5. **Matching Strategy (Hybrid Approach):**
When you identify an issue to comment on:
- **Exact match:** Same file + same line number → existing thread found
- **Nearby match:** Same file + line within ±5 → existing thread found
- **No match:** Create new inline comment
6. **Handling Evolved Issues:**
- **Issue persists unchanged:** Respond in existing thread with update
- **Issue resolved:** Note resolution in thread response (can mark as resolved if supported)
- **Issue changed significantly:** Resolve/close old thread, create new comment explaining evolution
### Step 2: Detect Change Type
### Step 2: Detect Change Type with Android Refinements
<thinking>
Analyze the changeset systematically:
@@ -60,17 +38,13 @@ Analyze the changeset systematically:
4. What's the risk level of these changes?
</thinking>
Analyze the changeset to determine the primary change type:
Use the base change type detection from the agent, with Android-specific refinements:
**Detection Rules:**
- **Dependency Update**: Only gradle files changed (`libs.versions.toml`, `build.gradle.kts`) with version number modifications
- **Bug Fix**: PR/commit title contains "fix", "bug", or issue ID; addresses existing broken behavior
- **Feature Addition**: New files, new ViewModels, significant new functionality
- **UI Refinement**: Only UI/Compose files changed, layout/styling focus
- **Refactoring**: Code restructuring without behavior change, pattern improvements
- **Infrastructure**: CI/CD files, Gradle config, build scripts, tooling changes
If changeset spans multiple types, use the most complex type's checklist.
**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
### Step 3: Load Appropriate Checklist
@@ -89,7 +63,7 @@ The checklist provides:
- What to check and what to skip
- Structured thinking guidance
### Step 4: Execute Review with Structured Thinking
### Step 4: Execute Review Following Checklist
<thinking>
Before diving into details:
@@ -102,7 +76,7 @@ Before diving into details:
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
### Step 5: Consult Reference Materials As Needed
### Step 5: Consult Android Reference Materials As Needed
Load reference files only when needed for specific questions:
@@ -115,206 +89,10 @@ Load reference files only when needed for specific questions:
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
### Step 6: Document Findings
## 🛑 STOP: Determine Output Format FIRST
<thinking>
Before writing ANY output, answer this critical question:
1. Did I find ANY issues (Critical, Important, Suggested, or Questions)?
2. If NO issues found → This is a CLEAN PR → Use 2-3 line minimal format and STOP
3. If issues found → Use verdict + critical issues list + inline comments format
4. NEVER create praise sections or elaborate on correct implementations
</thinking>
**Decision Tree:**
```
Do you have ANY issues to report (Critical/Important/Suggested/Questions)?
├─ NO → CLEAN PR
│ └─ Use 2-3 line format:
│ "**Overall Assessment:** APPROVE
│ [One sentence describing what PR does well]"
│ └─ STOP. Do not proceed to detailed format guidance.
└─ YES → PR WITH ISSUES
└─ Use minimal summary + inline comments:
"**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues:**
- [issue with file:line]
See inline comments for details."
```
## Special Case: Clean PRs with No Issues
When you find NO critical, important, or suggested issues:
**Minimal Approval Format (REQUIRED):**
```
**Overall Assessment:** APPROVE
[One sentence describing what the PR does well]
```
**Examples:**
- "Clean refactoring following established patterns"
- "Solid bug fix with comprehensive test coverage"
- "Well-structured feature implementation meeting all standards"
**NEVER do this for clean PRs:**
- ❌ Multiple sections (Key Strengths, Changes, Code Quality, etc.)
- ❌ Listing everything that was done correctly
- ❌ Checkmarks for each file or pattern followed
- ❌ Elaborate praise or detailed positive analysis
- ❌ Tables, statistics, or detailed breakdowns
**Why brevity matters:**
- Respects developer time (quick approval = move forward faster)
- Reduces noise in PR conversations
- Saves tokens and processing time
- Focuses attention on PRs that actually need discussion
**If you're tempted to write more than 3 lines for a clean PR, STOP. You're doing it wrong.**
---
<thinking>
Before writing each comment:
1. Is this issue Critical, Important, Suggested, or just Acknowledgment?
2. Should I ask a question or provide direction?
3. What's the rationale I need to explain?
4. What code example would make this actionable?
5. Is there a documentation reference to include?
</thinking>
**CRITICAL**: Use summary comment + inline comments approach.
**Review Comment Structure**:
- Create ONE summary comment with overall verdict + critical issues list
- Create separate inline comment for EACH specific issue on the exact line with full details
- Summary directs readers to inline comments ("See inline comments for details")
- Do NOT duplicate issue details between summary and inline comments
### CRITICAL: No Praise-Only Comments
❌ **NEVER** create inline comments solely for positive feedback
❌ **NEVER** create summary sections like "Strengths", "Good Practices", "What Went Well"
❌ **NEVER** use inline comments to elaborate on correct implementations
Focus exclusively on actionable feedback. Reserve comments for issues requiring attention.
**Inline Comment Format** (REQUIRED: Use `<details>` Tags):
**MUST use `<details>` tags for ALL inline comments.** Only severity + one-line description should be visible; all other content must be 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>
```
**Visibility Rule:**
- **Visible:** Severity prefix (emoji + text) + one-line description
- **Collapsed in `<details>`:** Code examples, rationale, explanations, references
**Example inline comment**:
```
⚠️ **CRITICAL**: Exposes mutable state
<details>
<summary>Details and fix</summary>
Change `MutableStateFlow<State>` to `StateFlow<State>`:
\```kotlin
private val _state = MutableStateFlow<State>()
val state: StateFlow<State> = _state.asStateFlow()
\```
Exposing MutableStateFlow allows external mutation, violating MVVM unidirectional data flow.
Reference: docs/ARCHITECTURE.md#mvvm-pattern
</details>
```
**Summary Comment Format (REQUIRED - No Exceptions):**
When you have issues to report, use this format ONLY:
```
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary with file:line reference]
See inline comments for all details.
```
**Maximum Length**: 5-10 lines total, regardless of PR size or complexity.
**No exceptions for**:
- ❌ Large PRs (10+ files)
- ❌ Multiple issue domains
- ❌ High-severity issues
- ❌ Complex changes
All details belong in inline comments with `<details>` tags, NOT in the summary.
**Output Format Rules**:
**What to Include:**
- **Inline comments**: Create separate comment for EACH specific issue with full details in `<details>` tag
- **Summary comment**: Overall assessment (APPROVE/REQUEST CHANGES) + list of CRITICAL issues only
- **Severity levels** (hybrid emoji + text format):
- ⚠️ **CRITICAL** (blocking)
- 📋 **IMPORTANT** (should fix)
- 💡 **SUGGESTED** (nice to have)
- ❓ **QUESTION** (seeking clarification)
**What to Exclude:**
- **No duplication**: Never repeat inline comment details in the summary
- **No Important/Suggested in summary**: Only CRITICAL blocking issues belong in summary
- **No "Good Practices"/"Strengths" sections**: Never include positive commentary sections
- **No "Action Items" section**: This duplicates inline comments - avoid entirely
- **No verbose analysis**: Keep detailed analysis (compilation status, security review, rollback plans) in inline comments only
### ❌ Common Anti-Patterns to Avoid
**DO NOT:**
- Create multiple summary sections (Strengths, Recommendations, Test Coverage Status, Architecture Compliance)
- Duplicate critical issues in both summary and inline comments
- Write elaborate descriptions in summary (details belong in inline comments)
- Exceed 5-10 lines for simple PRs
- Create inline comments that only provide praise
**DO:**
- Put verdict + critical issue list ONLY in summary
- Put ALL details (explanations, code, rationale) in inline comments with `<details>` collapse
- Keep summary to 5-10 lines maximum, regardless of PR size or your analysis depth
- Focus comments exclusively on actionable issues
**Visibility Guidelines:**
- **Inline comments visible**: Severity + one-line description only
- **Inline comments collapsed**: Code examples, rationale, references in `<details>` tag
- **Summary visible**: Verdict + critical issues list only
See `examples/review-outputs.md` for complete examples.
## Core Principles
- **Minimal reviews for clean PRs**: 2-3 lines when no issues found (see Step 6 format guidance)
- **Issues-focused feedback**: Only comment when there's something actionable; acknowledge good work briefly without elaboration (see priority-framework.md:145-166)
- **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
- **Constructive tone**: Ask questions for design decisions, explain rationale, focus on code not people
- **Efficient reviews**: Use multi-pass strategy, time-box reviews, skip what's not relevant
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms

View File

@@ -2,6 +2,27 @@
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
@@ -25,10 +46,11 @@ Reference: [docs link if applicable]
```
**Severity Levels:**
- ⚠️ **CRITICAL** - Blocking, must fix
- 📋 **IMPORTANT** - Should fix
- 💡 **SUGGESTED** - Nice to have
- **QUESTION** - Seeking clarification
- **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
@@ -81,7 +103,7 @@ See inline comments for migration details.
**Inline Comment 1** (on `network/api/BitwardenApiService.kt:34`):
```markdown
⚠️ **CRITICAL**: API migration required for Retrofit 3.0
**CRITICAL**: API migration required for Retrofit 3.0
<details>
<summary>Details and fix</summary>
@@ -136,7 +158,7 @@ See inline comments for all issues and suggestions.
**Inline Comment 1** (on `app/vault/unlock/UnlockViewModel.kt:78`):
```markdown
⚠️ **CRITICAL**: Exposes mutable state
**CRITICAL**: Exposes mutable state
<details>
<summary>Details and fix</summary>
@@ -160,7 +182,7 @@ Reference: docs/ARCHITECTURE.md#mvvm-pattern
**Inline Comment 2** (on `data/vault/UnlockRepository.kt:145`):
```markdown
⚠️ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
**CRITICAL**: PIN stored without encryption - SECURITY ISSUE
<details>
<summary>Details and fix</summary>
@@ -188,7 +210,7 @@ Reference: docs/ARCHITECTURE.md#security
**Inline Comment 3** (on `app/vault/unlock/UnlockViewModel.kt:92`):
```markdown
📋 **IMPORTANT**: Missing error handling test
⚠️ **IMPORTANT**: Missing error handling test
<details>
<summary>Details and fix</summary>
@@ -214,7 +236,7 @@ Ensures error flow remains robust across refactorings.
**Inline Comment 4** (on `app/vault/unlock/UnlockViewModel.kt:105`):
```markdown
💡 **SUGGESTED**: Consider rate limiting for PIN attempts
🎨 **SUGGESTED**: Consider rate limiting for PIN attempts
<details>
<summary>Details and fix</summary>
@@ -246,7 +268,7 @@ Would add security layer against brute force. Consider discussing threat model w
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
```markdown
**QUESTION**: Can we use BitwardenTextField?
💭 **QUESTION**: Can we use BitwardenTextField?
<details>
<summary>Details</summary>
@@ -356,7 +378,7 @@ This is exactly the right approach for fail-safe security.
**What NOT to do:**
```markdown
⚠️ **CRITICAL**: Missing test coverage for security-critical code
**CRITICAL**: Missing test coverage for security-critical code
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
@@ -382,7 +404,7 @@ Security-critical code should have the highest test coverage, not be omitted.
**Correct approach:**
```markdown
⚠️ **CRITICAL**: Missing test coverage for security-critical code
**CRITICAL**: Missing test coverage for security-critical code
<details>
<summary>Details and fix</summary>
@@ -422,5 +444,3 @@ Security-critical code should have the highest test coverage, not be omitted.
- Praise-only inline comments
- Duplication between summary and inline comments
- Verbose analysis in summary (belongs in inline comments)
See `SKILL.md` for complete review guidelines.

View File

@@ -2,6 +2,23 @@
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
@@ -194,6 +211,43 @@ abstract class DataModule {
---
### 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
```
@@ -283,6 +337,7 @@ Reference: `docs/ARCHITECTURE.md#error-handling`
- [ ] 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

View File

@@ -1,8 +1,28 @@
# Issue Priority Framework
# Finding Priority Framework
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
## Critical (Blocker - Must Fix Before Merge)
## 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.
@@ -49,7 +69,7 @@ This violates MVVM encapsulation pattern.
---
## Important (Should Fix)
## ⚠️ **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.
@@ -102,7 +122,53 @@ Fetching items one-by-one in loop. Consider batch fetch to reduce database queri
---
## Suggested (Nice to Have)
## ♻️ **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)
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
@@ -142,6 +208,80 @@ 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.
@@ -175,11 +315,26 @@ Note good practices to reinforce positive patterns. Keep these **brief** - list
- 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
@@ -270,5 +425,7 @@ Missing tests for refactored helper → SUGGESTED
**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

@@ -2,6 +2,20 @@
Effective code review feedback is clear, actionable, and constructive. This guide provides phrasing patterns for inline comments.
## Table of Contents
**Guidelines:**
- [Core Directives](#core-directives)
- [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)
---
## Core Directives
- **Keep positive feedback minimal**: For clean PRs with no issues, use 2-3 line approval only. When acknowledging good practices in PRs with issues, use single bullet list with no elaboration. Never create elaborate sections praising correct implementations.

View File

@@ -12,7 +12,7 @@ runs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -22,7 +22,7 @@ runs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -44,6 +44,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

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

@@ -0,0 +1,57 @@
{
"title_patterns": {
"t:feature-app": ["feat", "feature"],
"t:feature-tool": ["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"]
},
"path_patterns": {
"app:shared": [
"annotation/",
"core/",
"data/",
"network/",
"ui/",
"authenticatorbridge/",
"gradle/"
],
"app:password-manager": [
"app/",
"cxf/",
"testharness/"
],
"app:authenticator": [
"authenticator/"
],
"t:feature-tool": [
"testharness/"
],
"t:feature-app": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
".claude/",
"detekt-config.yml"
],
"t:docs": [
"docs/"
],
"t:deps": [
"gradle/"
],
"t:misc": [
"keystore/"
]
}
}

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

@@ -0,0 +1,33 @@
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: '✨ Community Highlight'
labels:
- community-pr
- title: '🚀 New Features & Enhancements'
labels:
- t:feature-app
- t:new-feature
- t:enhancement
- title: ':shipit: Tools'
labels:
- t:feature-tool
- 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

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

View File

@@ -79,7 +79,7 @@ jobs:
- 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
@@ -167,7 +167,7 @@ jobs:
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: version-info
path: version_info.json

View File

@@ -21,17 +21,19 @@ 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
@@ -59,7 +61,7 @@ jobs:
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
@@ -67,7 +69,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -77,7 +79,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -98,7 +100,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -123,7 +124,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
@@ -134,7 +135,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -173,7 +173,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
@@ -184,7 +184,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
@@ -198,7 +198,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- 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"
@@ -207,7 +207,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -217,7 +217,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -283,17 +283,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -311,38 +311,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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: |

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

@@ -0,0 +1,134 @@
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 }}
JAVA_VERSION: 21
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: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
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: 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"
- name: Build Test Harness Debug APK
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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

@@ -33,6 +33,8 @@ 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
@@ -61,7 +63,7 @@ jobs:
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
@@ -69,7 +71,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -79,7 +81,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -100,7 +102,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -111,7 +112,7 @@ jobs:
run: bundle exec fastlane assembleDebugApks
- name: Upload test reports on failure
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
with:
name: test-reports
@@ -132,7 +133,7 @@ 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
@@ -143,7 +144,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -186,7 +186,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
@@ -203,7 +203,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -213,7 +213,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -297,42 +297,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
@@ -368,52 +368,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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: |
@@ -421,8 +421,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: |
@@ -431,12 +431,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
@@ -451,7 +451,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
@@ -462,7 +462,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -491,7 +490,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
@@ -508,7 +507,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -518,7 +517,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -578,8 +577,8 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
@@ -590,15 +589,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
@@ -609,19 +608,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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

@@ -73,7 +73,7 @@ jobs:
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
@@ -83,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: {}

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

@@ -26,7 +26,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
@@ -34,7 +34,7 @@ jobs:
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -44,7 +44,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
@@ -65,7 +65,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
@@ -76,7 +75,7 @@ jobs:
bundle exec fastlane check
- name: Upload test reports
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: test-reports

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.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1181.0)
aws-sdk-core (3.236.0)
aws-partitions (1.1206.0)
aws-sdk-core (3.241.4)
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.117.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
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.5.0)
date (3.5.1)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -58,14 +55,14 @@ 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)
@@ -75,8 +72,9 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
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,23 +169,23 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.16.0)
json (2.18.0)
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.8.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (6.0.2)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
@@ -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

@@ -235,6 +235,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)

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

@@ -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" />
@@ -86,6 +87,7 @@
<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" />
@@ -138,6 +140,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" />
@@ -260,7 +275,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,21 @@
{
"apps": [
{
"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 +28,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

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

@@ -145,9 +145,6 @@ class AuthDiskSourceImpl(
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = 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 +159,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? =
@@ -330,7 +331,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 +373,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

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

@@ -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
@@ -163,7 +162,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

View File

@@ -49,14 +49,14 @@ class UserLogoutManagerImpl(
override fun logout(userId: String, reason: LogoutReason) {
authDiskSource.userState ?: return
Timber.d("logout reason=$reason")
val isExpired = reason == LogoutReason.SecurityStamp
if (isExpired) {
val isSecurityStamp = reason == LogoutReason.SecurityStamp
if (isSecurityStamp) {
showToast(message = BitwardenString.login_expired)
}
val ableToSwitchToNewAccount = switchUserIfAvailable(
currentUserId = userId,
isExpired = isExpired,
isSecurityStamp = isSecurityStamp,
removeCurrentUserFromAccounts = true,
)
@@ -73,25 +73,24 @@ class UserLogoutManagerImpl(
override fun softLogout(userId: String, reason: LogoutReason) {
Timber.d("softLogout reason=$reason")
val isExpired = reason == LogoutReason.SecurityStamp
if (isExpired) {
val isSecurityStamp = reason == LogoutReason.SecurityStamp
if (isSecurityStamp) {
showToast(message = BitwardenString.login_expired)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = null,
)
// Save any data that will still need to be retained after otherwise clearing all dat
// Save any data that will still need to be retained after otherwise clearing all data
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
val pinProtectedUserKeyEnvelope = authDiskSource
.getPinProtectedUserKeyEnvelope(userId = userId)
val encryptedPin = authDiskSource.getEncryptedPin(userId = userId)
val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId)
val pinProtectedUserKeyEnvelope = authDiskSource.getPinProtectedUserKeyEnvelope(
userId = userId,
)
switchUserIfAvailable(
currentUserId = userId,
removeCurrentUserFromAccounts = false,
isExpired = isExpired,
isSecurityStamp = isSecurityStamp,
)
clearData(userId = userId)
@@ -108,10 +107,14 @@ class UserLogoutManagerImpl(
vaultTimeoutAction = vaultTimeoutAction,
)
}
authDiskSource.storePinProtectedUserKeyEnvelope(
userId = userId,
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
)
authDiskSource.apply {
storeEncryptedPin(userId = userId, encryptedPin = encryptedPin)
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = pinProtectedUserKey)
storePinProtectedUserKeyEnvelope(
userId = userId,
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
)
}
}
private fun clearData(userId: String) {
@@ -133,7 +136,7 @@ class UserLogoutManagerImpl(
private fun switchUserIfAvailable(
currentUserId: String,
removeCurrentUserFromAccounts: Boolean,
isExpired: Boolean = false,
isSecurityStamp: Boolean,
): Boolean {
val currentUserState = authDiskSource.userState ?: return false
@@ -145,7 +148,7 @@ class UserLogoutManagerImpl(
// Check if there is a new active user
return if (updatedAccounts.isNotEmpty()) {
if (currentUserId == currentUserState.activeUserId && !isExpired) {
if (currentUserId == currentUserState.activeUserId && !isSecurityStamp) {
showToast(message = BitwardenString.account_switched_automatically)
}

View File

@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@@ -38,6 +39,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +50,7 @@ import kotlinx.coroutines.flow.StateFlow
interface AuthRepository :
AuthenticatorProvider,
AuthRequestManager,
BiometricsEncryptionManager,
KdfManager,
UserStateManager {
/**
@@ -357,14 +360,14 @@ interface AuthRepository :
suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult
/**
* Validates the master password for the current logged in user.
* Validates the master password for the current logged-in user.
*/
suspend fun validatePassword(password: String): ValidatePasswordResult
/**
* Validates the PIN for the current logged in user.
* Validates the PIN for the current logged-in user.
*/
suspend fun validatePin(pin: String): ValidatePinResult
suspend fun validatePinUserKey(pin: String): ValidatePinResult
/**
* Validates the given [password] against the master password
@@ -400,4 +403,11 @@ interface AuthRepository :
suspend fun leaveOrganization(
organizationId: String,
): LeaveOrganizationResult
/**
* Revokes self from the organization that matches the given [organizationId]
*/
suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult
}

View File

@@ -2,7 +2,10 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
@@ -12,6 +15,7 @@ import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.repository.util.toEnvironmentUrls
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
@@ -77,6 +81,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@@ -100,8 +105,8 @@ import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITER
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -112,6 +117,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -157,6 +163,7 @@ class AuthRepositoryImpl(
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRequestManager: AuthRequestManager,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val keyConnectorManager: KeyConnectorManager,
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
@@ -168,6 +175,7 @@ class AuthRepositoryImpl(
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
BiometricsEncryptionManager by biometricsEncryptionManager,
KdfManager by kdfManager,
UserStateManager by userStateManager {
/**
@@ -454,42 +462,32 @@ class AuthRepositoryImpl(
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { keys ->
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = keys.publicKey,
encryptedPrivateKey = keys.privateKey,
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { keys }
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
.flatMap { keys ->
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = keys.adminReset,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { keys }
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { keys ->
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
createNewSsoUserSuccess(
userId = userId,
privateKey = keys.privateKey,
createAccountKeysResponse = createAccountKeysResponse,
registerTdeKeyResponse = registerTdeKeyResponse,
)
// Order matters here, we need to make sure that the vault is unlocked
// before we trust the device, to avoid state-base navigation issues.
vaultRepository.syncVaultState(userId = userId)
keys.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
}
.fold(
@@ -498,6 +496,37 @@ class AuthRepositoryImpl(
)
}
/**
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
* updated once after data stored.
*/
private suspend fun createNewSsoUserSuccess(
userId: String,
createAccountKeysResponse: CreateAccountKeysResponseJson,
registerTdeKeyResponse: RegisterTdeKeyResponse,
): Unit = userStateManager.userStateTransaction {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
override suspend fun completeTdeLogin(
requestPrivateKey: String,
asymmetricalKey: String,
@@ -514,6 +543,7 @@ class AuthRepositoryImpl(
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@@ -521,10 +551,13 @@ class AuthRepositoryImpl(
},
) {
unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
@@ -1287,7 +1320,7 @@ class AuthRepositoryImpl(
}
}
override suspend fun validatePin(pin: String): ValidatePinResult {
override suspend fun validatePinUserKey(pin: String): ValidatePinResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
@@ -1296,13 +1329,13 @@ class AuthRepositoryImpl(
val pinProtectedUserKeyEnvelope = authDiskSource
.getPinProtectedUserKeyEnvelope(userId = activeAccount.userId)
?: return ValidatePinResult.Error(
error = MissingPropertyException("Pin Protected User Key"),
error = MissingPropertyException("Pin Protected User Key Envelope"),
)
return vaultSdkSource
.validatePin(
.validatePinUserKey(
userId = activeAccount.userId,
pin = pin,
pinProtectedUserKey = pinProtectedUserKeyEnvelope,
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
)
.fold(
onSuccess = { ValidatePinResult.Success(isValid = it) },
@@ -1356,10 +1389,10 @@ class AuthRepositoryImpl(
)
.fold(
onSuccess = {
when (val json = it) {
when (it) {
VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
is VerifyEmailTokenResponseJson.Invalid -> {
EmailTokenResult.Error(message = json.message, error = null)
EmailTokenResult.Error(message = it.message, error = null)
}
VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
@@ -1384,6 +1417,14 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
override suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult =
organizationService.revokeFromOrganization(organizationId).fold(
onSuccess = { RevokeFromOrganizationResult.Success },
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
@@ -1806,14 +1847,23 @@ class AuthRepositoryImpl(
)
.map {
unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = key,
),
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
)
}
.fold(
@@ -1834,11 +1884,21 @@ class AuthRepositoryImpl(
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val accountKeys = loginResponse.accountKeys
val result = unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = keyConnectorResponse.keys.private,
securityState = accountKeys
?.securityState
?.securityState,
signingKey = accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
@@ -1883,27 +1943,30 @@ class AuthRepositoryImpl(
// Attempt to unlock the vault with password if possible.
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKeyOrNull() ?: return null
val key = loginResponse.key ?: return null
val initUserCryptoMethod = loginResponse
val masterPasswordUnlock = loginResponse
.userDecryptionOptions
?.masterPasswordUnlock
?.let { masterPasswordUnlock ->
InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
)
}
?: InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
)
?: return null
val initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
)
return unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = initUserCryptoMethod,
)
}
@@ -1911,6 +1974,7 @@ class AuthRepositoryImpl(
/**
* Attempt to unlock the current user's vault with trusted device specific data.
*/
@Suppress("LongMethod")
private suspend fun unlockVaultWithTdeOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
profile: AccountJson.Profile,
@@ -1923,10 +1987,19 @@ class AuthRepositoryImpl(
if (privateKey != null && key != null) {
deviceData?.let { model ->
return unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = loginResponse.accountKeys
?.securityState
?.securityState,
signingKey = loginResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
signedPublicKey = loginResponse.accountKeys
?.publicKeyEncryptionKeyPair
?.signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
@@ -1956,9 +2029,18 @@ class AuthRepositoryImpl(
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
securityState = accountKeys.securityState?.securityState,
signingKey = accountKeys.signatureKeyPair?.wrappedSigningKey,
privateKey = accountKeys
.publicKeyEncryptionKeyPair
.wrappedPrivateKey,
securityState = accountKeys
.securityState
?.securityState,
signedPublicKey = accountKeys
.publicKeyEncryptionKeyPair
.signedPublicKey,
signingKey = accountKeys
.signatureKeyPair
?.wrappedSigningKey,
)
}
?: loginResponse.privateKey
@@ -1968,6 +2050,7 @@ class AuthRepositoryImpl(
profile = profile,
privateKey = privateKey,
securityState = null,
signedPublicKey = null,
signingKey = null,
)
}
@@ -1983,6 +2066,7 @@ class AuthRepositoryImpl(
profile: AccountJson.Profile,
privateKey: String,
securityState: String?,
signedPublicKey: String?,
signingKey: String?,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
@@ -2000,10 +2084,13 @@ class AuthRepositoryImpl(
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
@@ -2029,10 +2116,13 @@ class AuthRepositoryImpl(
}
vaultUnlockResult = unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
@@ -2050,20 +2140,16 @@ class AuthRepositoryImpl(
* A helper function to unlock the vault for the user associated with the [accountProfile].
*/
private suspend fun unlockVault(
accountCryptographicState: WrappedAccountCryptographicState,
accountProfile: AccountJson.Profile,
privateKey: String,
securityState: String?,
signingKey: String?,
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val userId = accountProfile.userId
return vaultRepository.unlockVault(
accountCryptographicState = accountCryptographicState,
userId = userId,
email = accountProfile.email,
kdf = accountProfile.toSdkParams(),
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = initUserCryptoMethod,
// The value for the organization keys here will typically be null. We can separately
// unlock the vault for organization data after receiving the sync response if this

View File

@@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManagerImpl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -60,6 +61,7 @@ object AuthRepositoryModule {
environmentRepository: EnvironmentRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
biometricsEncryptionManager: BiometricsEncryptionManager,
keyConnectorManager: KeyConnectorManager,
authRequestManager: AuthRequestManager,
trustedDeviceManager: TrustedDeviceManager,
@@ -85,6 +87,7 @@ object AuthRepositoryModule {
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
biometricsEncryptionManager = biometricsEncryptionManager,
keyConnectorManager = keyConnectorManager,
authRequestManager = authRequestManager,
trustedDeviceManager = trustedDeviceManager,

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of deleting an account.
* Models result of leaving an organization.
*/
sealed class LeaveOrganizationResult {
/**

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of leaving an organization.
*/
sealed class RevokeFromOrganizationResult {
/**
* Revoke from organization succeeded.
*/
data object Success : RevokeFromOrganizationResult()
/**
* There was an error revoking from the organization.
*/
data class Error(
val error: Throwable?,
) : RevokeFromOrganizationResult()
}

View File

@@ -5,7 +5,11 @@ import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
private const val DUO_HOST: String = "duo-callback"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "duo-callback"
/**
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
@@ -18,11 +22,28 @@ private const val DUO_HOST: String = "duo-callback"
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
*/
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
val localData = data
return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) {
localData.getDuoCallbackTokenResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getDuoCallbackTokenResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getDuoCallbackTokenResult()
} else {
null
}
}
else -> null
}
}

View File

@@ -4,14 +4,20 @@ import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.browser.auth.AuthTabIntent
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.parcelize.Parcelize
import java.net.URLEncoder
import java.security.MessageDigest
import java.util.Base64
private const val SSO_HOST: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$SSO_HOST"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$CALLBACK"
/**
* Generates a URI for the SSO custom tab.
@@ -28,7 +34,7 @@ fun generateUriForSso(
token: String,
state: String,
codeVerifier: String,
): String {
): Uri {
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
val encodedToken = URLEncoder.encode(token, "UTF-8")
@@ -39,7 +45,7 @@ fun generateUriForSso(
.digest(codeVerifier.toByteArray()),
)
return "$identityBaseUrl/connect/authorize" +
val uri = "$identityBaseUrl/connect/authorize" +
"?client_id=mobile" +
"&redirect_uri=$redirectUri" +
"&response_type=code" +
@@ -50,6 +56,7 @@ fun generateUriForSso(
"&response_mode=query" +
"&domain_hint=$encodedOrganizationIdentifier" +
"&ssoToken=$encodedToken"
return uri.toUri()
}
/**
@@ -62,11 +69,28 @@ fun generateUriForSso(
* - [SsoCallbackResult.Success]: Intent is the SSO callback with required data.
*/
fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
val localData = data
return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) {
localData.getSsoCallbackResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getSsoCallbackResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getSsoCallbackResult()
} else {
null
}
}
else -> null
}
}

View File

@@ -11,8 +11,13 @@ import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private const val WEB_AUTH_HOST: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
@@ -22,14 +27,28 @@ private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
*/
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
val localData = data
return if (action == Intent.ACTION_VIEW &&
localData != null &&
localData.host == WEB_AUTH_HOST
) {
localData.getWebAuthResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getWebAuthResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getWebAuthResult()
} else {
null
}
}
else -> null
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.auth.util
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
/**
@@ -14,7 +14,7 @@ fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData?
newValue = "/",
ignoreCase = true,
)
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
val uri = runCatching { sanitizedUriString.toUri() }.getOrNull() ?: return null
uri.host ?: return null
if (uri.path != "/finish-signup") return null
val email = uri.getQueryParameter("email") ?: return null

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.net.Uri
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import java.net.URISyntaxException
@@ -10,7 +11,7 @@ import java.net.URISyntaxException
@OmitFromCoverage
fun String.toUriOrNull(): Uri? =
try {
Uri.parse(this)
} catch (e: URISyntaxException) {
this.toUri()
} catch (_: URISyntaxException) {
null
}

View File

@@ -93,7 +93,6 @@ class AutofillParserImpl(
val urlBarWebsite = traversalDataList
.flatMap { it.urlBarWebsites }
.firstOrNull()
?.takeIf { settingsRepository.isAutofillWebDomainCompatMode }
// Take only the autofill views from the node that currently has focus.
// Then remove all the fields that cannot be filled with data.

View File

@@ -68,6 +68,8 @@ class AutofillCipherProviderImpl(
it.type is CipherListViewType.Card &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE &&
// Must not be restricted by organization.
@@ -106,6 +108,8 @@ class AutofillCipherProviderImpl(
it.type is CipherListViewType.Login &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE
}

View File

@@ -11,10 +11,10 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.vault.CipherListView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupported
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
/**
* Primary implementation of [CredentialEntryBuilder].
@@ -22,7 +22,7 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
class CredentialEntryBuilderImpl(
private val context: Context,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val authRepository: AuthRepository,
) : CredentialEntryBuilder {
override fun buildPublicKeyCredentialEntries(
@@ -82,7 +82,7 @@ class CredentialEntryBuilderImpl(
.also { builder ->
if (!isUserVerified) {
builder.setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
cipher = authRepository.getOrCreateCipher(userId),
)
}
}
@@ -113,8 +113,7 @@ class CredentialEntryBuilderImpl(
.apply {
if (!isUserVerified) {
setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager
.getOrCreateCipher(userId),
cipher = authRepository.getOrCreateCipher(userId),
)
}
}

View File

@@ -25,7 +25,6 @@ import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepositoryIm
import com.x8bit.bitwarden.data.credentials.sanitizer.PasskeyAttestationOptionsSanitizer
import com.x8bit.bitwarden.data.credentials.sanitizer.PasskeyAttestationOptionsSanitizerImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -54,7 +53,6 @@ object CredentialProviderModule {
bitwardenCredentialManager: BitwardenCredentialManager,
dispatcherManager: DispatcherManager,
pendingIntentManager: CredentialManagerPendingIntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
clock: Clock,
): CredentialProviderProcessor =
CredentialProviderProcessorImpl(
@@ -63,7 +61,6 @@ object CredentialProviderModule {
bitwardenCredentialManager = bitwardenCredentialManager,
pendingIntentManager = pendingIntentManager,
clock = clock,
biometricsEncryptionManager = biometricsEncryptionManager,
dispatcherManager = dispatcherManager,
)
@@ -108,11 +105,11 @@ object CredentialProviderModule {
fun provideCredentialEntryBuilder(
@ApplicationContext context: Context,
pendingIntentManager: CredentialManagerPendingIntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
authRepository: AuthRepository,
): CredentialEntryBuilder = CredentialEntryBuilderImpl(
context = context,
pendingIntentManager = pendingIntentManager,
biometricsEncryptionManager = biometricsEncryptionManager,
authRepository = authRepository,
)
@Provides

View File

@@ -258,6 +258,7 @@ class BitwardenCredentialManagerImpl(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
userHandle = null,
)
.fold(
onSuccess = { it },

View File

@@ -58,6 +58,13 @@ interface CredentialManagerPendingIntentManager {
userId: String,
): PendingIntent
/**
* Creates a pending intent to use when providing options for Password credential creation.
*/
fun createPasswordCreationPendingIntent(
userId: String,
): PendingIntent
/**
* Creates a pending intent to use when providing options for Password credential filling.
*/

View File

@@ -75,6 +75,24 @@ class CredentialManagerPendingIntentManagerImpl(
)
}
/**
* Creates a pending intent to use when providing options for FIDO 2 credential creation.
*/
override fun createPasswordCreationPendingIntent(
userId: String,
): PendingIntent {
val intent = Intent(CREATE_PASSWORD_ACTION)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ Random.nextInt(),
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
/**
* Creates a pending intent to use when providing options for Password credential filling.
*/
@@ -101,4 +119,5 @@ class CredentialManagerPendingIntentManagerImpl(
private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
private const val CREATE_PASSWORD_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD"
private const val GET_PASSWORD_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD"

View File

@@ -49,6 +49,7 @@ class OriginManagerImpl(
)
.fold(
onSuccess = {
Timber.d("Digital asset link validation result: linked = ${it.linked}")
if (it.linked) {
ValidateOriginResult.Success(null)
} else {
@@ -56,6 +57,7 @@ class OriginManagerImpl(
}
},
onFailure = {
Timber.e("Failed to validate origin for calling app")
ValidateOriginResult.Error.AssetLinkNotFound
},
)
@@ -105,7 +107,7 @@ class OriginManagerImpl(
.fold(
onSuccess = { it },
onFailure = {
Timber.e(it, "Failed to validate privileged app: ${callingAppInfo.packageName}")
Timber.e(it, "Failed to validate calling app is privileged.")
ValidateOriginResult.Error.Unknown
},
)

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.credentials.model
import android.os.Bundle
import android.os.Parcelable
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.provider.ProviderCreateCredentialRequest
@@ -48,6 +49,15 @@ data class CreateCredentialRequest(
providerRequest.callingRequest as? CreatePublicKeyCredentialRequest
}
/**
* The [CreatePasswordRequest] of the [providerRequest], or null if the calling
* request is not a [CreatePasswordRequest].
*/
@IgnoredOnParcel
val createPasswordCredentialRequest: CreatePasswordRequest? by lazy {
providerRequest.callingRequest as? CreatePasswordRequest
}
/**
* The [requestJson] of the [createPublicKeyCredentialRequest], or null if the calling request
* is not a [CreatePublicKeyCredentialRequest].

View File

@@ -19,6 +19,7 @@ import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.AuthenticationAction
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
@@ -33,9 +34,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.Clock
import javax.crypto.Cipher
@@ -51,7 +52,6 @@ class CredentialProviderProcessorImpl(
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val clock: Clock,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
dispatcherManager: DispatcherManager,
) : CredentialProviderProcessor {
@@ -62,21 +62,27 @@ class CredentialProviderProcessorImpl(
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
Timber.d("Create credential request received.")
val userId = authRepository.activeUserId
if (userId == null) {
Timber.w("No active user. Cannot create credential.")
callback.onError(CreateCredentialUnknownException("Active user is required."))
return
}
val createCredentialJob = ioScope.launch {
processCreateCredentialRequest(request = request)
(handleCreatePasskeyQuery(request) ?: handleCreatePasswordQuery(request))
?.let { callback.onResult(it) }
?: callback.onError(CreateCredentialUnknownException())
?: run {
Timber.w("Unknown create credential request.")
callback.onError(CreateCredentialUnknownException())
}
}
cancellationSignal.setOnCancelListener {
if (createCredentialJob.isActive) {
createCredentialJob.cancel()
}
Timber.d("Create credential request cancelled by system.")
callback.onError(CreateCredentialCancellationException())
}
}
@@ -86,15 +92,18 @@ class CredentialProviderProcessorImpl(
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
Timber.d("Get credential request received.")
// If the user is not logged in, return an error.
val userState = authRepository.userStateFlow.value
if (userState == null) {
Timber.w("No active user. Cannot get credentials.")
callback.onError(GetCredentialUnknownException("Active user is required."))
return
}
// Return an unlock action if the current account is locked.
if (!userState.activeAccount.isVaultUnlocked) {
Timber.d("Vault is locked. Requesting unlock.")
val authenticationAction = AuthenticationAction(
title = context.getString(BitwardenString.unlock),
pendingIntent = pendingIntentManager.createFido2UnlockPendingIntent(
@@ -119,10 +128,17 @@ class CredentialProviderProcessorImpl(
BeginGetCredentialRequest.asBundle(request),
),
)
.onSuccess { callback.onResult(BeginGetCredentialResponse(credentialEntries = it)) }
.onFailure { callback.onError(GetCredentialUnknownException(it.message)) }
.onSuccess {
Timber.d("Credentials retrieved.")
callback.onResult(BeginGetCredentialResponse(credentialEntries = it))
}
.onFailure {
Timber.w("Error getting credentials.")
callback.onError(GetCredentialUnknownException(it.message))
}
}
cancellationSignal.setOnCancelListener {
Timber.d("Get credential request cancelled by system.")
callback.onError(GetCredentialCancellationException())
getCredentialJob.cancel()
}
@@ -134,24 +150,15 @@ class CredentialProviderProcessorImpl(
callback: OutcomeReceiver<Void?, ClearCredentialException>,
) {
// no-op: RFU
Timber.w("Unsupported clear credential state request received.")
callback.onError(ClearCredentialUnsupportedException())
}
private fun processCreateCredentialRequest(
private fun handleCreatePasskeyQuery(
request: BeginCreateCredentialRequest,
): BeginCreateCredentialResponse? {
return when (request) {
is BeginCreatePublicKeyCredentialRequest -> {
handleCreatePasskeyQuery(request)
}
if (request !is BeginCreatePublicKeyCredentialRequest) return null
else -> null
}
}
private fun handleCreatePasskeyQuery(
request: BeginCreatePublicKeyCredentialRequest,
): BeginCreateCredentialResponse? {
val requestJson = request
.candidateQueryData
.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
@@ -161,14 +168,19 @@ class CredentialProviderProcessorImpl(
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId))
.setCreateEntries(
userState.accounts.toCreatePasskeyEntry(userState.activeUserId),
)
.build()
}
private fun List<UserState.Account>.toCreateEntries(activeUserId: String) =
map { it.toCreateEntry(isActive = activeUserId == it.userId) }
private fun List<UserState.Account>.toCreatePasskeyEntry(
activeUserId: String,
): List<CreateEntry> = map { it.toCreatePasskeyEntry(isActive = activeUserId == it.userId) }
private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry {
private fun UserState.Account.toCreatePasskeyEntry(
isActive: Boolean,
): CreateEntry {
val accountName = name ?: email
val entryBuilder = CreateEntry
.Builder(
@@ -189,7 +201,55 @@ class CredentialProviderProcessorImpl(
.setAutoSelectAllowed(true)
if (isVaultUnlocked) {
biometricsEncryptionManager
authRepository
.getOrCreateCipher(userId)
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
}
return entryBuilder.build()
}
private fun handleCreatePasswordQuery(
request: BeginCreateCredentialRequest,
): BeginCreateCredentialResponse? {
if (request !is BeginCreatePasswordCredentialRequest) return null
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(
userState.accounts.toCreatePasswordEntry(userState.activeUserId),
)
.build()
}
private fun List<UserState.Account>.toCreatePasswordEntry(
activeUserId: String,
) = map { it.toCreatePasswordEntry(isActive = activeUserId == it.userId) }
private fun UserState.Account.toCreatePasswordEntry(
isActive: Boolean,
): CreateEntry {
val accountName = name ?: email
val entryBuilder = CreateEntry
.Builder(
accountName = accountName,
pendingIntent = pendingIntentManager.createPasswordCreationPendingIntent(
userId = userId,
),
)
.setDescription(
context.getString(
BitwardenString.your_password_will_be_saved_to_your_bitwarden_vault_for_x,
accountName,
),
)
// Set the last used time to "now" so the active account is the default option in the
// system prompt.
.setLastUsedTime(if (isActive) clock.instant() else null)
.setAutoSelectAllowed(true)
if (isVaultUnlocked) {
authRepository
.getOrCreateCipher(userId)
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
}

View File

@@ -7,6 +7,7 @@ import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.core.util.isHyperOS
import javax.crypto.Cipher
/**
@@ -15,7 +16,7 @@ import javax.crypto.Cipher
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PublicKeyCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
if (isBiometricPromptDataSupported() && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)
@@ -29,10 +30,19 @@ fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
fun PasswordCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PasswordCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
if (isBiometricPromptDataSupported() && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)
} else {
this
}
/**
* Returns whether biometric prompt data is supported on this device.
* Note: Xiaomi HyperOS is known to be incompatible.
*/
private fun isBiometricPromptDataSupported(): Boolean {
return isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
!isHyperOS()
}

View File

@@ -30,6 +30,7 @@ class EventDiskSourceImpl(
},
cipherId = event.cipherId,
date = event.date,
organizationId = event.organizationId,
),
)
}
@@ -48,6 +49,7 @@ class EventDiskSourceImpl(
},
cipherId = it.cipherId,
date = it.date,
organizationId = it.organizationId,
)
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@@ -13,7 +13,7 @@ import java.time.Instant
* Primary access point for general settings-related disk information.
*/
@Suppress("TooManyFunctions")
interface SettingsDiskSource {
interface SettingsDiskSource : FlightRecorderDiskSource {
/**
* The currently persisted app language (or `null` if not set).
@@ -95,31 +95,34 @@ interface SettingsDiskSource {
*/
val hasUserLoggedInOrCreatedAccountFlow: Flow<Boolean?>
/**
* The current status of whether the flight recorder is enabled.
*/
var flightRecorderData: FlightRecorderDataSet?
/**
* Emits updates that track [flightRecorderData].
*/
val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
/**
* The time at which the browser autofill dialog is allowed to be shown to the user again.
*/
var browserAutofillDialogReshowTime: Instant?
/**
* The current status of whether the web domain compatibility mode is enabled.
*/
var isAutofillWebDomainCompatMode: Boolean?
/**
* Clears all the settings data for the given user.
*/
fun clearData(userId: String)
/**
* Retrieves the stored value of whether the introducing archive action card has been dismissed.
*/
fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean?
/**
* Stores whether the introducing archive action card has been dismissed.
*/
fun storeIntroducingArchiveActionCardDismissed(
userId: String,
isDismissed: Boolean?,
)
/**
* Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId].
*/
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -5,8 +5,8 @@ import androidx.core.content.edit
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseDiskSource
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@@ -47,10 +47,10 @@ private const val CREATE_ACTION_COUNT = "createActionCount"
private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark"
private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark"
private const val RESUME_SCREEN = "resumeScreen"
private const val FLIGHT_RECORDER_KEY = "flightRecorderData"
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
private const val AUTOFILL_WEB_DOMAIN_COMPATIBILITY = "autofillWebDomainCompatibility"
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
/**
* Primary implementation of [SettingsDiskSource].
@@ -59,8 +59,10 @@ private const val AUTOFILL_WEB_DOMAIN_COMPATIBILITY = "autofillWebDomainCompatib
class SettingsDiskSourceImpl(
private val sharedPreferences: SharedPreferences,
private val json: Json,
flightRecorderDiskSource: FlightRecorderDiskSource,
) : BaseDiskSource(sharedPreferences = sharedPreferences),
SettingsDiskSource {
SettingsDiskSource,
FlightRecorderDiskSource by flightRecorderDiskSource {
private val mutableAppLanguageFlow = bufferedMutableSharedFlow<AppLanguage?>(replay = 1)
private val mutableAppThemeFlow = bufferedMutableSharedFlow<AppTheme>(replay = 1)
@@ -87,14 +89,15 @@ class SettingsDiskSourceImpl(
private val mutableShowImportLoginsSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableFlightRecorderDataFlow = bufferedMutableSharedFlow<FlightRecorderDataSet?>()
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasSeenGeneratorCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -215,32 +218,12 @@ class SettingsDiskSourceImpl(
get() = mutableHasUserLoggedInOrCreatedAccountFlow
.onSubscription { emit(getBoolean(HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY)) }
override var flightRecorderData: FlightRecorderDataSet?
get() = getString(key = FLIGHT_RECORDER_KEY)
?.let { json.decodeFromStringOrNull<FlightRecorderDataSet>(it) }
set(value) {
putString(
key = FLIGHT_RECORDER_KEY,
value = value?.let { json.encodeToString(it) },
)
mutableFlightRecorderDataFlow.tryEmit(value)
}
override val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) }
override var browserAutofillDialogReshowTime: Instant?
get() = getLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME)?.let { Instant.ofEpochMilli(it) }
set(value) {
putLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME, value = value?.toEpochMilli())
}
override var isAutofillWebDomainCompatMode: Boolean?
get() = getBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY)
set(value) {
putBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY, value = value)
}
override fun clearData(userId: String) {
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)
storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null)
@@ -262,8 +245,29 @@ class SettingsDiskSourceImpl(
// - show unlock setting badge
// - should show add login coach mark
// - should show generator coach mark
// - should show introducing archive action card dismissed
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
getBoolean(
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
)
override fun storeIntroducingArchiveActionCardDismissed(
userId: String,
isDismissed: Boolean?,
) {
putBoolean(
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
value = isDismissed,
)
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
}
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -601,6 +605,13 @@ class SettingsDiskSourceImpl(
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -14,8 +15,11 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
entities = [
OrganizationEventEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
],
)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class PlatformDatabase : RoomDatabase() {

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.room.Room
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
@@ -139,10 +140,12 @@ object PlatformDiskModule {
fun provideSettingsDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
flightRecorderDiskSource: FlightRecorderDiskSource,
): SettingsDiskSource =
SettingsDiskSourceImpl(
sharedPreferences = sharedPreferences,
json = json,
flightRecorderDiskSource = flightRecorderDiskSource,
)
@Provides

View File

@@ -25,4 +25,7 @@ data class OrganizationEventEntity(
@ColumnInfo(name = "date")
val date: ZonedDateTime,
@ColumnInfo(name = "organization_id")
val organizationId: String?,
)

View File

@@ -32,7 +32,6 @@ interface BiometricsEncryptionManager {
*/
fun isBiometricIntegrityValid(
userId: String,
cipher: Cipher?,
): Boolean
/**

View File

@@ -90,8 +90,8 @@ class BiometricsEncryptionManagerImpl(
return cipher?.takeIf { isCipherInitialized }
}
override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean =
isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId)
override fun isBiometricIntegrityValid(userId: String): Boolean =
isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId)
override fun isAccountBiometricIntegrityValid(userId: String): Boolean {
val systemBioIntegrityState = settingsDiskSource
@@ -203,11 +203,13 @@ class BiometricsEncryptionManagerImpl(
}
/**
* Validates the keystore key and decrypts it using the user-provided [cipher].
* Validates the keystore key and decrypts it, if decryption is successful `true` is returned,
* `false` otherwise.
*/
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
private fun isSystemBiometricIntegrityValid(userId: String): Boolean {
// Attempt to get the user scoped key. If that fails, we check to see if a legacy key
// is available.
val cipher = getOrCreateCipher(userId = userId)
val secretKey = getSecretKeyOrNull(userId = userId) ?: getSecretKeyOrNull(userId = null)
return if (cipher != null && secretKey != null) {
cipher.initializeCipher(userId = userId, secretKey = secretKey)

View File

@@ -7,9 +7,9 @@ import android.security.KeyChainException
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsCertificate
import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsKeyHost
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.manager.model.ImportPrivateKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import timber.log.Timber

View File

@@ -25,4 +25,10 @@ interface PolicyManager {
userId: String,
type: PolicyTypeJson,
): List<SyncResponseJson.Policy>
/**
* Get the organization id of the personal ownership policy.
* If multiple organizations enforce the policy, return the first to set it.
*/
fun getPersonalOwnershipPolicyOrganizationId(): String?
}

View File

@@ -66,6 +66,13 @@ class PolicyManagerImpl(
)
.orEmpty()
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
this
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.sortedBy { it.revisionDate }
.firstOrNull()
?.organizationId
/**
* A helper method to filter policies.
*/

View File

@@ -34,6 +34,8 @@ import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
import javax.inject.Inject
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaDuration
@@ -134,7 +136,6 @@ class PushManagerImpl @Inject constructor(
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun onMessageReceived(notification: BitwardenNotification) {
if (authDiskSource.uniqueAppId == notification.contextId) return
val userId = activeUserId ?: return
Timber.d("Push Notification Received: ${notification.notificationType}")
when (val type = notification.notificationType) {
@@ -179,11 +180,13 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncCipherNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.takeIf { it.cipherId != null && it.revisionDate != null }
.takeIf {
it.cipherId != null && it.revisionDate != null && isLoggedIn(it.userId)
}
?.let {
mutableSyncCipherUpsertSharedFlow.tryEmit(
SyncCipherUpsertData(
userId = requireNotNull(it.userId),
cipherId = requireNotNull(it.cipherId),
revisionDate = requireNotNull(it.revisionDate),
organizationId = it.organizationId,
@@ -228,11 +231,13 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncFolderNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.takeIf { it.folderId != null && it.revisionDate != null }
.takeIf {
it.folderId != null && it.revisionDate != null && isLoggedIn(it.userId)
}
?.let {
mutableSyncFolderUpsertSharedFlow.tryEmit(
SyncFolderUpsertData(
userId = requireNotNull(it.userId),
folderId = requireNotNull(it.folderId),
revisionDate = requireNotNull(it.revisionDate),
isUpdate = type == NotificationType.SYNC_FOLDER_UPDATE,
@@ -273,11 +278,13 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncSendNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.takeIf { it.sendId != null && it.revisionDate != null }
.takeIf {
it.sendId != null && it.revisionDate != null && isLoggedIn(it.userId)
}
?.let {
mutableSyncSendUpsertSharedFlow.tryEmit(
SyncSendUpsertData(
userId = requireNotNull(it.userId),
sendId = requireNotNull(it.sendId),
revisionDate = requireNotNull(it.revisionDate),
isUpdate = type == NotificationType.SYNC_SEND_UPDATE,
@@ -361,11 +368,11 @@ class PushManagerImpl @Inject constructor(
)
}
@OptIn(ExperimentalContracts::class)
private fun isLoggedIn(
userId: String,
): Boolean = authDiskSource.getAccountTokens(userId)?.isLoggedIn == true
}
private fun NotificationPayload.userMatchesNotification(userId: String): Boolean {
return this.userId != null && this.userId == userId
userId: String?,
): Boolean {
contract { returns(true) implies (userId != null) }
return userId?.let { authDiskSource.getAccountTokens(it) }?.isLoggedIn == true
}
}

View File

@@ -63,10 +63,6 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManagerImpl
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriter
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriterImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
@@ -84,7 +80,6 @@ import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
@@ -109,34 +104,6 @@ object PlatformManagerModule {
application: Application,
): AppStateManager = AppStateManagerImpl(application = application)
@Provides
@Singleton
fun provideFlightRecorderWriter(
clock: Clock,
fileManager: FileManager,
dispatcherManager: DispatcherManager,
): FlightRecorderWriter = FlightRecorderWriterImpl(
clock = clock,
fileManager = fileManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideFlightRecorderManager(
@ApplicationContext context: Context,
clock: Clock,
dispatcherManager: DispatcherManager,
settingsDiskSource: SettingsDiskSource,
flightRecorderWriter: FlightRecorderWriter,
): FlightRecorderManager = FlightRecorderManagerImpl(
context = context,
clock = clock,
dispatcherManager = dispatcherManager,
settingsDiskSource = settingsDiskSource,
flightRecorderWriter = flightRecorderWriter,
)
@Provides
@Singleton
fun provideAuthenticatorBridgeProcessor(

View File

@@ -79,6 +79,7 @@ class OrganizationEventManagerImpl(
type = event.type,
cipherId = event.cipherId,
date = ZonedDateTime.now(clock),
organizationId = event.organizationId,
),
)
}

View File

@@ -16,11 +16,17 @@ sealed class OrganizationEvent {
*/
abstract val cipherId: String?
/**
* The optional organization ID.
*/
abstract val organizationId: String?
/**
* Tracks when a value is successfully auto-filled
*/
data class CipherClientAutoFilled(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_AUTO_FILLED
@@ -31,6 +37,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedCardCode(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_CARD_CODE
@@ -41,6 +48,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedHiddenField(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_HIDDEN_FIELD
@@ -51,6 +59,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedPassword(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_PASSWORD
@@ -61,6 +70,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledCardCodeVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_CODE_VISIBLE
@@ -71,6 +81,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledCardNumberVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_NUMBER_VISIBLE
@@ -81,6 +92,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledHiddenFieldVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_HIDDEN_FIELD_VISIBLE
@@ -91,6 +103,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledPasswordVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_PASSWORD_VISIBLE
@@ -101,6 +114,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientViewed(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_VIEWED
@@ -111,7 +125,32 @@ sealed class OrganizationEvent {
*/
data object UserClientExportedVault : OrganizationEvent() {
override val cipherId: String? = null
override val organizationId: String? = null
override val type: OrganizationEventType
get() = OrganizationEventType.USER_CLIENT_EXPORTED_VAULT
}
/**
* Tracks when a user's personal ciphers have been migrated to their organization's My Items
* folder as required by the organization's personal vault ownership policy.
*/
data class ItemOrganizationAccepted(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
}
/**
* Tracks when a user chooses to leave an organization instead of migrating their personal
* ciphers to their organization's My Items folder.
*/
data class ItemOrganizationDeclined(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
}
}

View File

@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
/**
* Required data for sync cipher upsert operations.
*
* @property userId The user ID associated with this update.
* @property cipherId The cipher ID.
* @property revisionDate The cipher's revision date. This is used to determine if the local copy of
* the cipher is out-of-date.
* @property isUpdate Whether or not this is an update of an existing cipher.
*/
data class SyncCipherUpsertData(
val userId: String,
val cipherId: String,
val revisionDate: ZonedDateTime,
val organizationId: String?,

View File

@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
/**
* Required data for sync folder upsert operations.
*
* @property userId The user ID associated with this update.
* @property folderId The folder ID.
* @property revisionDate The folder's revision date. This is used to determine if the local copy of
* the folder is out-of-date.
* @property isUpdate Whether or not this is an update of an existing folder.
*/
data class SyncFolderUpsertData(
val userId: String,
val folderId: String,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,

View File

@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
/**
* Required data for sync send upsert operations.
*
* @property userId The user ID associated with this update.
* @property sendId The send ID.
* @property revisionDate The send's revision date. This is used to determine if the local copy of
* the send is out-of-date.
* @property isUpdate Whether or not this is an update of an existing send.
*/
data class SyncSendUpsertData(
val userId: String,
val sendId: String,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,

View File

@@ -7,6 +7,7 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.SIGNAL_STRENGTH_UNSPECIFIED
import android.net.NetworkRequest
import android.util.Log
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
@@ -38,6 +39,7 @@ class NetworkConnectionManagerImpl(
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
init {
Log.d("NetworkConnectionManager", "init called")
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@@ -96,22 +98,26 @@ class NetworkConnectionManagerImpl(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
Log.d("NetworkConnectionManager", "onCapabilitiesChanged called")
super.onCapabilitiesChanged(network, networkCapabilities)
mutableConnectionState.tryEmit(Unit)
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
super.onLinkPropertiesChanged(network, linkProperties)
Log.d("NetworkConnectionManager", "onLinkPropertiesChanged called")
mutableConnectionState.tryEmit(Unit)
}
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("NetworkConnectionManager", "onAvailable called")
mutableConnectionState.tryEmit(Unit)
}
override fun onLost(network: Network) {
super.onLost(network)
Log.d("NetworkConnectionManager", "onLost called")
mutableConnectionState.tryEmit(Unit)
}
}

View File

@@ -4,18 +4,19 @@ import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@@ -137,17 +138,21 @@ class AuthenticatorBridgeRepositoryImpl(
?.securityState
?.securityState
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@@ -155,11 +155,6 @@ interface SettingsRepository : FlightRecorderManager {
*/
var isAutofillSavePromptDisabled: Boolean
/**
* Whether or not the autofill web domain parsing is enabled.
*/
var isAutofillWebDomainCompatMode: Boolean
/**
* A list of blocked autofill URI's for the current user.
*/
@@ -247,6 +242,16 @@ interface SettingsRepository : FlightRecorderManager {
*/
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
/**
* Gets updates for whether the introducing archive action card is dismissed.
*/
fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean>
/**
* Stores that the introducing archive action card has been dismissed for the active user.
*/
fun dismissIntroducingArchiveActionCard()
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
import android.view.autofill.AutofillManager
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
@@ -16,7 +17,6 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@@ -279,7 +279,7 @@ class SettingsRepositoryImpl(
get() = activeUserId
?.let { userId ->
authDiskSource
.getUserBiometicUnlockKeyFlow(userId)
.getUserBiometricUnlockKeyFlow(userId)
.map { it != null }
}
?: flowOf(false)
@@ -338,12 +338,6 @@ class SettingsRepositoryImpl(
)
}
override var isAutofillWebDomainCompatMode: Boolean
get() = settingsDiskSource.isAutofillWebDomainCompatMode ?: false
set(value) {
settingsDiskSource.isAutofillWebDomainCompatMode = value
}
override var blockedAutofillUris: List<String>
get() = activeUserId
?.let { settingsDiskSource.getBlockedAutofillUris(userId = it) }
@@ -506,6 +500,29 @@ class SettingsRepositoryImpl(
}
}
override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean> {
val userId = activeUserId ?: return MutableStateFlow(value = false)
return settingsDiskSource
.getIntroducingArchiveActionCardDismissedFlow(userId = userId)
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.getIntroducingArchiveActionCardDismissed(userId = userId)
?: false,
)
}
override fun dismissIntroducingArchiveActionCard() {
activeUserId?.let {
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
userId = it,
isDismissed = true,
)
}
}
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
val userId = activeUserId
?: return BiometricsKeyResult.Error(error = NoActiveUserException())

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.di
import android.view.autofill.AutofillManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
@@ -10,7 +11,6 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository
import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository

View File

@@ -14,10 +14,36 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
/**
* Invokes the [observer] callback whenever the user is logged in, the active changes, and there
* are subscribers to the [MutableStateFlow]. The flow from all previous calls to the `observer`
* is canceled whenever the `observer` is re-invoked, there is no active user (logged-out), or
* there are no subscribers to the [MutableStateFlow].
* Lazily invokes the [observer] callback with the active user's ID only when this MutableStateFlow
* has external collectors and a user is logged in. Designed for operations that should only run
* when UI actively observes the resulting data, but do not require the vault to be unlocked.
*
* **Active User Tracking:**
* This function specifically tracks the active user from [userStateFlow]. When the active user
* changes (e.g., account switching), the previous observer flow is canceled and a new one is
* started for the new active user.
*
* **Subscription Detection:**
* Uses [MutableStateFlow.subscriptionCount] to detect external collectors. Only external
* `.collect()` calls increment subscriptionCount—internal flow operations (map, flatMapLatest,
* update, etc.) do not affect it.
*
* **Common Pattern:**
* ```kotlin
* private val _triggerFlow = MutableStateFlow(Unit)
* val dataFlow = _triggerFlow
* .observeWhenSubscribedAndLoggedIn(userFlow) { activeUserId ->
* repository.getData(activeUserId) // Only runs when dataFlow is collected
* }
* // _triggerFlow.update {} does NOT affect subscriptionCount
* ```
*
* **Observer Lifecycle:**
* - **Invoked** when subscriptionCount > 0 and a user is logged in
* - **Re-invoked** when the active user changes (account switch)
* - **Canceled** when subscribers disconnect or user logs out
*
* @see observeWhenSubscribedAndUnlocked for variant that also requires vault to be unlocked
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
@@ -35,11 +61,36 @@ fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
}
/**
* Invokes the [observer] callback whenever the user is logged in, the active changes,
* the vault for the user changes and there are subscribers to the [MutableStateFlow].
* The flow from all previous calls to the `observer`
* is canceled whenever the `observer` is re-invoked, there is no active user (logged-out),
* there are no subscribers to the [MutableStateFlow] or the vault is not unlocked.
* Lazily invokes the [observer] callback with the active user's ID only when this MutableStateFlow
* has external collectors, a user is logged in, and the active user's vault is unlocked. Designed
* for expensive operations that should only run when UI actively observes the resulting data.
*
* **Active User Tracking:**
* This function specifically tracks the active user from [userStateFlow]. When the active user
* changes (e.g., account switching), the previous observer flow is canceled and a new one is
* started for the new active user. The vault unlock state is also tracked per-user.
*
* **Subscription Detection:**
* Uses [MutableStateFlow.subscriptionCount] to detect external collectors. Only external
* `.collect()` calls increment subscriptionCount—internal flow operations (map, flatMapLatest,
* update, etc.) do not affect it.
*
* **Common Pattern:**
* ```kotlin
* private val _triggerFlow = MutableStateFlow(Unit)
* val dataFlow = _triggerFlow
* .observeWhenSubscribedAndUnlocked(userFlow, unlockFlow) { activeUserId ->
* repository.getExpensiveData(activeUserId) // Only runs when dataFlow is collected
* }
* // _triggerFlow.update {} does NOT affect subscriptionCount
* ```
*
* **Observer Lifecycle:**
* - **Invoked** when subscriptionCount > 0, a user is logged in, and active user's vault unlocked
* - **Re-invoked** when the active user changes (account switch) or vault state changes
* - **Canceled** when subscribers disconnect, user logs out, or vault locks
*
* @see observeWhenSubscribedAndLoggedIn for variant without vault unlock requirement
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndUnlocked(

View File

@@ -24,6 +24,16 @@ interface VaultDiskSource {
*/
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Checks if the user has any personal ciphers (ciphers not belonging to an organization).
*
* This is an optimized query that checks only the indexed organizationId column
* without loading full cipher JSON data. Intended for vault migration state checks.
*
* @return Flow that emits true if user has personal ciphers, false otherwise
*/
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
/**
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
*/

View File

@@ -55,6 +55,7 @@ class VaultDiskSourceImpl(
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
),
),
)
@@ -97,6 +98,9 @@ class VaultDiskSourceImpl(
}
}
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
ciphersDao.hasPersonalCiphersFlow(userId = userId)
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
@@ -295,6 +299,7 @@ class VaultDiskSourceImpl(
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
)
},
)

View File

@@ -88,4 +88,21 @@ interface CiphersDao {
insertCiphers(ciphers)
return deletedCiphersCount > 0 || ciphers.isNotEmpty()
}
/**
* Checks if the user has any personal ciphers (ciphers with null organizationId).
* Returns a Flow that emits true if personal ciphers exist, false otherwise.
*
* This query is optimized for vault migration checks and uses the indexed
* organization_id column to avoid loading full cipher JSON.
*/
@Query("""
SELECT EXISTS(
SELECT 1 FROM ciphers
WHERE user_id = :userId
AND organization_id IS NULL
LIMIT 1
)
""")
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
}

View File

@@ -27,7 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
FolderEntity::class,
SendEntity::class,
],
version = 8,
version = 9,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 6, to = 7),

View File

@@ -2,18 +2,25 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity representing a cipher in the database.
*/
@Entity(tableName = "ciphers")
@Entity(
tableName = "ciphers",
indices = [
Index(value = ["user_id"]),
Index(value = ["user_id", "organization_id"]),
],
)
data class CipherEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "user_id", index = true)
@ColumnInfo(name = "user_id")
val userId: String,
// Default to true for initial migration.
@@ -26,4 +33,9 @@ data class CipherEntity(
@ColumnInfo(name = "cipher_json")
val cipherJson: String,
// Extracted organizationId for query optimization to avoid loading full cipher JSON.
// Enables lightweight queries for vault migration checks and organization filtering.
@ColumnInfo(name = "organization_id")
val organizationId: String?,
)

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
@@ -97,12 +98,13 @@ interface VaultSdkSource {
): Result<EnrollPinResponse>
/**
* Validate the user pin using the [pinProtectedUserKey].
* Validates that the given PIN with the encrypted user key and returns `true` if the PIN is
* correct, otherwise `false`.
*/
suspend fun validatePin(
suspend fun validatePinUserKey(
userId: String,
pin: String,
pinProtectedUserKey: String,
pinProtectedUserKeyEnvelope: String,
): Result<Boolean>
/**
@@ -388,6 +390,16 @@ interface VaultSdkSource {
cipherView: CipherView,
): Result<CipherView>
/**
* Re-encrypts the [cipherViews] with the organizations encryption key into the respective [collectionIds]
*/
suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>>
/**
* Validates that the given password matches the password hash.
*/
@@ -487,6 +499,7 @@ interface VaultSdkSource {
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
userHandle: String?,
): Result<List<Fido2CredentialAutofillView>>
/**

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DeriveKeyConnectorException
import com.bitwarden.core.DeriveKeyConnectorRequest
@@ -100,6 +101,7 @@ class VaultSdkSourceImpl(
is DeriveKeyConnectorException.WrongPassword -> {
DeriveKeyConnectorResult.WrongPasswordError
}
is DeriveKeyConnectorException.Crypto -> {
DeriveKeyConnectorResult.Error(error = ex)
}
@@ -129,15 +131,18 @@ class VaultSdkSourceImpl(
.enrollPinWithEncryptedPin(encryptedPin = encryptedPin)
}
override suspend fun validatePin(
override suspend fun validatePinUserKey(
userId: String,
pin: String,
pinProtectedUserKey: String,
pinProtectedUserKeyEnvelope: String,
): Result<Boolean> =
runCatchingWithLogs {
getClient(userId = userId)
.auth()
.validatePin(pin = pin, pinProtectedUserKey = pinProtectedUserKey)
.validatePinProtectedUserKeyEnvelope(
pin = pin,
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
)
}
override suspend fun getAuthRequestKey(
@@ -447,6 +452,22 @@ class VaultSdkSourceImpl(
.moveToOrganization(cipher = cipherView, organizationId = organizationId)
}
override suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>> = runCatchingWithLogs {
getClient(userId = userId)
.vault()
.ciphers()
.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
}
override suspend fun validatePassword(
userId: String,
password: String,
@@ -599,6 +620,7 @@ class VaultSdkSourceImpl(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
userHandle: String?,
): Result<List<Fido2CredentialAutofillView>> = runCatchingWithLogs {
getClient(userId)
.platform()
@@ -607,7 +629,7 @@ class VaultSdkSourceImpl(
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
credentialStore = fido2CredentialStore,
)
.silentlyDiscoverCredentials(relyingPartyId)
.silentlyDiscoverCredentials(relyingPartyId, userHandle?.toByteArray())
}
override suspend fun makeUpdateKdf(

View File

@@ -28,7 +28,7 @@ class Fido2CredentialAuthenticationUserInterfaceImpl(
newCredential: Fido2CredentialNewView,
): CheckUserAndPickCredentialForCreationResult = throw IllegalStateException()
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
override fun isVerificationEnabled(): Boolean = isVerificationSupported
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,

View File

@@ -32,7 +32,7 @@ class Fido2CredentialRegistrationUserInterfaceImpl(
checkUserResult = CheckUserResult(userPresent = true, userVerified = true),
)
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
override fun isVerificationEnabled(): Boolean = isVerificationSupported
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,

View File

@@ -29,7 +29,7 @@ class Fido2CredentialSearchUserInterfaceImpl : Fido2UserInterface {
// Always return true for this property because any problems with verification should
// be handled downstream where the app can actually offer verification methods.
override suspend fun isVerificationEnabled(): Boolean = true
override fun isVerificationEnabled(): Boolean = true
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,

View File

@@ -42,7 +42,11 @@ class Fido2CredentialStoreImpl(
* @param ids Optional list of FIDO 2 credential ID's to find.
* @param ripId Relying Party ID to find.
*/
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> =
override suspend fun findCredentials(
ids: List<ByteArray>?,
ripId: String,
userHandle: ByteArray?,
): List<CipherView> =
vaultRepository
.decryptCipherListResultStateFlow
.value

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
@@ -10,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
/**
@@ -57,6 +59,22 @@ interface CipherManager {
*/
suspend fun getCipher(cipherId: String): GetCipherResult
/**
* Attempt to archive a cipher.
*/
suspend fun archiveCipher(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult
/**
* Attempt to unarchive a cipher.
*/
suspend fun unarchiveCipher(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult
/**
* Attempt to delete a cipher.
*/
@@ -115,4 +133,12 @@ interface CipherManager {
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult
/**
* Migrate the attachments if they don't have their own key
*/
suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView>
}

View File

@@ -6,6 +6,8 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.manager.model.DownloadResult
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
@@ -17,6 +19,7 @@ import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.EncryptionContext
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
@@ -24,8 +27,8 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
@@ -33,6 +36,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
@@ -53,6 +57,7 @@ import java.time.Clock
class CipherManagerImpl(
private val fileManager: FileManager,
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val ciphersService: CiphersService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
@@ -158,6 +163,76 @@ class CipherManagerImpl(
)
}
override suspend fun archiveCipher(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult {
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = encryptionContext.cipher,
)
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = clock.instant()),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { ArchiveCipherResult.Success },
onFailure = { ArchiveCipherResult.Error(error = it) },
)
}
override suspend fun unarchiveCipher(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult {
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = encryptionContext.cipher,
)
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = null),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { UnarchiveCipherResult.Success },
onFailure = { UnarchiveCipherResult.Error(error = it) },
)
}
override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult {
val userId = activeUserId
?: return DeleteCipherResult.Error(error = NoActiveUserException())
@@ -610,7 +685,7 @@ class CipherManagerImpl(
}
}
private suspend fun migrateAttachments(
override suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView> {
@@ -689,7 +764,7 @@ class CipherManagerImpl(
* for now.
*/
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
val userId = activeUserId ?: return
val userId = syncCipherUpsertData.userId
val cipherId = syncCipherUpsertData.cipherId
val organizationId = syncCipherUpsertData.organizationId
val collectionIds = syncCipherUpsertData.collectionIds
@@ -732,6 +807,12 @@ class CipherManagerImpl(
}
if (!shouldUpdate) return
if (activeUserId != userId) {
// We cannot update right now since the accounts do not match, so we will
// do a full-sync on the next check.
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
return
}
ciphersService
.getCipher(cipherId = cipherId)

View File

@@ -6,6 +6,7 @@ import com.bitwarden.network.model.UpdateFolderResponseJson
import com.bitwarden.network.service.FolderService
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
@@ -25,8 +26,10 @@ import kotlinx.coroutines.flow.onEach
/**
* The default implementation of the [FolderManager].
*/
@Suppress("LongParameterList")
class FolderManagerImpl(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val folderService: FolderService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
@@ -148,7 +151,7 @@ class FolderManagerImpl(
* are met.
*/
private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) {
val userId = activeUserId ?: return
val userId = syncFolderUpsertData.userId
val folderId = syncFolderUpsertData.folderId
val isUpdate = syncFolderUpsertData.isUpdate
val revisionDate = syncFolderUpsertData.revisionDate
@@ -162,6 +165,12 @@ class FolderManagerImpl(
localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
if (activeUserId != userId) {
// We cannot update right now since the accounts do not match, so we will
// do a full-sync on the next check.
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
return
}
folderService
.getFolder(folderId = folderId)

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.vault.manager
/**
* Manager class to manage the pin-protected user key.
*/
interface PinProtectedUserKeyManager {
/**
* Checks if the given [userId] has an associated encrypted PIN key but not a pin-protected
* user key. This indicates a scenario in which a user has requested PIN unlocking but requires
* master-password unlocking on app restart. This function may then be called after such an
* unlock to derive a pin-protected user key and store it in memory for use for any subsequent
* unlocks during this current app session.
*
* If the user's vault has not yet been unlocked, this call will do nothing.
*
* @param userId The ID of the user to check.
*/
suspend fun deriveTemporaryPinProtectedUserKeyIfNecessary(userId: String)
/**
* Migrates the PIN-protected user key for the given user if needed.
*
* If an encrypted PIN exists and no PIN-protected user key envelope is present, enrolls the
* PIN with the encrypted PIN and stores the resulting envelope.
* Optionally marks the envelope as in-memory only if the PIN-protected user key is not present.
*
* @param userId The ID of the user for whom to migrate the PIN-protected user key.
*/
suspend fun migratePinProtectedUserKeyIfNeeded(userId: String)
}

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