Compare commits

...

127 Commits

Author SHA1 Message Date
Patrick Honkonen
e31fa46a73
[PM-30279] Extract credential provider handling to dedicated activity (#6472)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:05:28 +00:00
David Perez
aff8b0347b
Update test tools (#6468) 2026-02-03 21:54:42 +00:00
renovate[bot]
f4d34e4649
[deps]: Update androidx.credentials:credentials to v1.6.0-rc01 (#6455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 21:35:42 +00:00
David Perez
2e18b079f8
Update Androidx dependencies (#6467) 2026-02-03 18:29:31 +00:00
aj-rosado
b0eea88af2
[PM-31613] Add send email verification feature flag (#6470) 2026-02-03 17:09:15 +00:00
Patrick Honkonen
4cac4d6a6e
Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 14:33:58 +00:00
David Perez
a2ec99fb05
Remove the configuration cache to avoid play store build issues (#6466) 2026-02-02 19:10:20 +00:00
Patrick Honkonen
d49629de9e
Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00
renovate[bot]
c85cbb70a1
[deps]: Lock file maintenance (#6460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 18:33:13 +00:00
github-actions[bot]
e482820201
Update Google privileged browsers list (#6452)
Co-authored-by: GitHub Actions Bot <actions@github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2026-02-02 17:59:57 +00:00
Shamim Shahrier Emon
74d45c3906
[PM-31393] Sends: UI/UX inconsistency of the password field (#6435) 2026-02-02 17:58:19 +00:00
Lucas
12eb42097c
[PM-30259] Add iodéOS browser to community FIDO2 privileged list (#6298) 2026-02-02 17:34:29 +00:00
David Perez
0811d14606
PM-31603: Add toast when resetpassword succeeds (#6465) 2026-02-02 17:26:01 +00:00
Ruyut
365067e5be
[PM-31583] Fix typos in authentication-related KDoc comments (#6461) 2026-02-02 15:29:31 +00:00
bw-ghapp[bot]
9652c7e049
Crowdin Pull (#6453)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-02 14:14:55 +00:00
bw-ghapp[bot]
6cc519bc3f
Update SDK to 2.0.0-4835-5285d3fc (#6446)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-02 14:07:02 +00:00
aj-rosado
9f82b42e36
[BWA-182] Add mTLS support for Glide image loading (#6125)
Co-authored-by: David Perez <david@livefront.com>
2026-01-30 19:57:59 +00:00
Patrick Honkonen
5531b478d3
Add comprehensive tests for FileManagerImpl (#6425)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 19:18:53 +00:00
Ruyut
fe5b61bf25
[PM-31445] Fix minor KDoc typos and wording issues. (#6441) 2026-01-30 19:15:03 +00:00
Patrick Honkonen
92ba38c831
[PM-31446] fix:Append assetlinks.json path to DAL URLs (#6447) 2026-01-30 18:22:00 +00:00
Patrick Honkonen
675b346666
Add comprehensive tests for ExportViewModel (#6442)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:16:24 +00:00
Patrick Honkonen
0f087b7d15
Add comprehensive tests for AuthenticatorRepositoryImpl (#6424)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 15:41:21 +00:00
Álison Fernandes
99a6dd7647
[PM-31436] Consolidate Feature categories in release notes and add labels (#6439) 2026-01-30 14:01:08 +00:00
bw-ghapp[bot]
ea4df7dde9
Update SDK to 2.0.0-4818-c1e4bb66 (#6444)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-30 12:08:39 +00:00
Álison Fernandes
f541919d39
[PM-31292] ci: update renovate config to remove gradle group and ignore sdk updates (#6437) 2026-01-29 21:06:49 +00:00
Amy Galles
3d1f46983a
use option to determine if release will be marked latest (#6417) 2026-01-29 18:41:36 +00:00
David Perez
b0084d2f1f
Set cache problem to warning (#6436) 2026-01-29 16:35:10 +00:00
David Perez
0d0a5cb292
Item migration flow has been moved into a graph (#6427) 2026-01-29 15:16:02 +00:00
bw-ghapp[bot]
ebfe293c81
Update SDK to 2.0.0-4800-bed92cae (#6431)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-29 13:49:01 +00:00
Patrick Honkonen
254b2cd25b
Add comprehensive tests for Import Parsers and UuidManager (#6423)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 21:55:06 +00:00
Patrick Honkonen
3d974d710c
[PM-31370] Refactor stringToUri and consolidate FileManager (#6432)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:37:24 +00:00
bw-ghapp[bot]
7717a09c06
Update SDK to 2.0.0-4772-490c1be4 (#6395)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-28 18:55:02 +00:00
David Perez
674cff1c3c
PM-31363: Fix crash caused by a duplicate ID (#6428) 2026-01-28 18:45:57 +00:00
Álison Fernandes
ca9ec45548
[PM-31343] Fix dependencies listed under Maintenance by adding a new fallback section to release.yml (#6420) 2026-01-28 14:59:34 +00:00
David Perez
009136ce1e
Minor cleanup of the MigrateToMyItemsScreen (#6421) 2026-01-28 14:59:22 +00:00
David Perez
19a3697605
Remove intialization of NetworkConnectionManager from application class (#6419) 2026-01-28 14:57:10 +00:00
David Perez
954571ff4a
Optimize build times (#6418) 2026-01-27 19:01:20 +00:00
David Perez
66316e4bd2
Cleanup organizations (#6391) 2026-01-27 17:28:09 +00:00
David Perez
9463cf646b
Update Kotlin and associated dependencies (#6408) 2026-01-27 17:14:39 +00:00
David Perez
e81710c24f
GradlewWrapper updates (#6415) 2026-01-27 17:14:24 +00:00
David Perez
71466405fa
Update testing tools (#6407) 2026-01-27 15:21:52 +00:00
David Perez
618bdc7424
Update protobufs to v4.33.4 (#6414) 2026-01-27 15:21:30 +00:00
David Perez
0f05e30997
Update the Compose BOM to v2026.01.00 (#6401) 2026-01-27 15:21:13 +00:00
David Perez
006a13d5ac
Update Sonarqube to v7.2.2.6593 (#6406) 2026-01-26 21:48:01 +00:00
David Perez
1d35004999
Update the Gradle Wrapper to the latest version (#6405) 2026-01-26 17:42:53 +00:00
David Perez
85249987aa
Update app version name to 2026.2.0 (#6409) 2026-01-26 17:42:27 +00:00
bw-ghapp[bot]
f05cf773fb
Crowdin Pull (#6412)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-26 14:54:34 +00:00
Patrick Honkonen
2e311b6c4a
[PM-30899] Store account keys upon SSO user creation (#6384) 2026-01-23 19:51:25 +00:00
David Perez
ee5ed77bc1
Update to Junit v6.0.2 (#6402) 2026-01-23 18:43:33 +00:00
aj-rosado
04a3cd227e
[PM-30644] Removing special circumstance validation from MigrateToMyItems route (#6358) 2026-01-23 17:06:31 +00:00
aj-rosado
ec28dde6d2
[PM-31081] Added snackbar when items are successfully migrated (#6394) 2026-01-23 16:51:41 +00:00
David Perez
319872ccf9
PM-29693: Add introducing archive action card to vault screen (#6390) 2026-01-23 16:50:43 +00:00
aj-rosado
9f1fad8be0
[PM-28990] Skipping vault migration on Network or Timeout error (#6393) 2026-01-23 16:06:17 +00:00
aj-rosado
0395d489c2
[PM-31069] Add OrganizationId support for Vault Migration operations (#6397) 2026-01-23 16:05:55 +00:00
David Perez
2acf429f67
PM-29696: Add action card for lapsed premium subscription (#6389) 2026-01-23 15:24:00 +00:00
David Perez
721fbbb82c
PM-31162: Update copy on the snackbar for archive feature (#6399) 2026-01-23 15:07:27 +00:00
David Perez
6d198bd8c9
Update to Firebase v34.8.0 (#6396) 2026-01-23 15:07:09 +00:00
Álison Fernandes
8658f1d42c
[PM-14880] ci: Address automated PR labeling workflow feedback (#6400) 2026-01-22 21:25:09 +00:00
Shamim Shahrier Emon
acc3e24d65
[PM-30664] Unlock with PIN doesn’t appear as enabled after enabling ‘Require master password on app restart’ (#6344) 2026-01-21 18:42:02 +00:00
bw-ghapp[bot]
40c8346bf7
Update SDK to 2.0.0-4676-0544ddec (#6388)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-21 17:24:18 +00:00
aj-rosado
a7badf8b0b
[PM-28470] Implement revoke from organization (#6383)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 16:53:26 +00:00
David Perez
c52910e74a
PM-31043: Add unarchive button to overflow menus (#6387) 2026-01-21 16:50:30 +00:00
David Perez
afc1ff4d7a
PM-31042: Add overflow archive button (#6385) 2026-01-21 14:50:00 +00:00
bw-ghapp[bot]
8cb4fab1de
Update SDK to 2.0.0-4672-b3e4ea24 (#6371)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-21 11:24:07 +00:00
David Perez
f79113aa7f
Fix minor typos (#6386) 2026-01-20 21:49:38 +00:00
renovate[bot]
7d814df04e
[deps]: Lock file maintenance (#6382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:45:36 +00:00
David Perez
49b208f013
PM-29697: Finish View and Edit Cipher UI for archive (#6377) 2026-01-20 18:43:14 +00:00
bw-ghapp[bot]
8d33e6660a
Crowdin Pull (#6380)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-20 15:48:58 +00:00
David Perez
27a0f5172c
Move Vault Listing Dialog clicks to VaultItemListingHandlers (#6375) 2026-01-16 19:50:26 +00:00
David Perez
3e470ebc25
PM-30868: Archive Banner on Edit Item Screen (#6367) 2026-01-16 19:48:15 +00:00
aj-rosado
eb18ca04a0
[PM-28471] Migrate individual vault to organization (#6352) 2026-01-16 19:11:43 +00:00
David Perez
759e0563a9
PM-30897: Add archive and unarchive button on Edit Cipher Screen (#6372) 2026-01-16 17:17:19 +00:00
David Perez
757f444493
PM-29694: Update archive empty state (#6369) 2026-01-15 18:29:16 +00:00
David Perez
98ba1690bf
PM-30807: Add archived header to ViewItem Screen (#6362) 2026-01-15 15:45:01 +00:00
David Perez
44274a888e
PM-30795: Update cipher filtering logic for archive (#6359) 2026-01-15 15:14:26 +00:00
bw-ghapp[bot]
77cc0d5fba
Update SDK to 2.0.0-4524-513f18bf (#6361)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-15 15:13:40 +00:00
Álison Fernandes
026393384b
[PM-30823] ci: Fix BWA Play Store publishing for rc cherry picks and update upload step names (#6360) 2026-01-15 14:42:45 +00:00
Patrick Honkonen
7daeaca63e
refactor(claude): Refine reviewing-changes skill description for clarity and usage (#6366) 2026-01-15 13:59:58 +00:00
Gavin Gui
353e7e9a4e
[PM-30394] PM-29960: Skip biometric prompt on Xiaomi HyperOS (#6316) 2026-01-14 16:08:57 +00:00
bw-ghapp[bot]
2d824f96f5
Update SDK to 2.0.0-4505-df9bd639 (#6355)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-14 16:00:14 +00:00
David Perez
a9b1623f8b
PM-30774: Add archiving and unarchiving network requests (#6356) 2026-01-14 14:49:01 +00:00
David Perez
6d72d3a1c9
PM-30767: Add archive row to Vault Screen (#6354) 2026-01-13 21:41:45 +00:00
David Perez
f6edc19595
Remove the unused showDivider flag from BitwardenGroupItem (#6353) 2026-01-13 16:42:51 +00:00
David Perez
45125a94c2
Update archive string with noun suffix (#6351) 2026-01-13 16:38:05 +00:00
David Perez
66900f71df
End subtext and end icon support to BitwardenGroupItem (#6349) 2026-01-13 15:27:32 +00:00
bw-ghapp[bot]
d12c546c9a
Update SDK to 2.0.0-4498-7681828f (#6350)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-13 13:08:36 +00:00
David Perez
be365eec1c
PM-30708: Add archive item navigation (#6348) 2026-01-12 21:30:04 +00:00
Álison Fernandes
d86959b375
[PM-14880] ci: Update feature labels (#6346) 2026-01-12 18:45:23 +00:00
bw-ghapp[bot]
282cce8ce0
Update SDK to 2.0.0-4479-ad9fb51d (#6345)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-12 17:00:34 +00:00
bw-ghapp[bot]
e8eaf4e68c
Crowdin Pull (#6342)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-12 15:06:13 +00:00
Patrick Honkonen
41dfc2b6e8
Improve KDoc on StateFlowExtensions (#6338) 2026-01-09 16:38:43 +00:00
Patrick Honkonen
7bfd4b5a6c
Document best practices for Clock/Time handling (#6340) 2026-01-09 14:53:38 +00:00
bw-ghapp[bot]
557b667dab
Update SDK to 2.0.0-4441-c5a3b833 (#6333)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-09 14:37:39 +00:00
aj-rosado
eff4ce7abb
[PM-28468] Updated validation and navigation for MigrateToMyItems (#6279)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-08 21:29:28 +00:00
David Perez
577e3c04e3
Add the Archive items feature flag (#6337) 2026-01-08 20:55:04 +00:00
David Perez
203313eb1d
Improve clock usage patterns (#6336) 2026-01-08 20:54:33 +00:00
David Perez
5d308aa95f
PM-30522: Add support for processing app links for Duo, WebAuthn, and SSO (#6332) 2026-01-07 19:45:04 +00:00
David Perez
c4a94cf5d1
Add concrete FlightRecorderDiskSource (#6281) 2026-01-07 19:30:53 +00:00
Lucas
5245a7a0c7
[PM-30258] Remove CalyxOS Chromium from the FIDO2 privileged list (#6297) 2026-01-07 17:13:59 +00:00
bw-ghapp[bot]
9432df6ff4
Update SDK to 2.0.0-4408-ef987b96 (#6331)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-07 16:33:15 +00:00
David Perez
461e1e1ff9
Update generated SSO uri to be typed (#6329) 2026-01-06 21:02:36 +00:00
David Perez
a8ef32ae76
Allow trailing commas in JSON (#6326) 2026-01-06 15:40:37 +00:00
Patrick Honkonen
769bfc83af
[VULN-362] Move Compose tooling dependency to debugImplementation (#6327) 2026-01-06 15:25:20 +00:00
Patrick Honkonen
29d84d69f5
[PM-28271] Rename validatePin to validatePinUserKey and update SDK usage (#6323) 2026-01-05 22:08:21 +00:00
David Perez
05d003edb2
Update Firebase BOM to latest versions (#6324) 2026-01-05 21:22:59 +00:00
bw-ghapp[bot]
03562a8605
Update SDK to 2.0.0-4373-3c666766 (#6311)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-05 18:27:32 +00:00
Lucas
e6c46169fb
[PM-30260] Add WebLibre to the FIDO2 privileged community list (#6299) 2026-01-05 18:24:21 +00:00
David Perez
7d4d7a25b5
Update Androidx dependencies (#6322) 2026-01-05 17:06:28 +00:00
renovate[bot]
1cb37b8458
[deps]: Lock file maintenance (#6320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:05:52 +00:00
David Perez
3c7b70f325
PM-30389: Allow for different auth tab schemes (#6315) 2026-01-05 16:36:17 +00:00
David Perez
9a8c504c8b
Move TestHelpers to core test-fixtures module (#6314) 2026-01-05 15:57:13 +00:00
bw-ghapp[bot]
b07a92f7d6
Crowdin Pull (#6317)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-05 15:29:42 +00:00
Mick Letofsky
674cde9869
Revert review Code Triggered by labeled event (#6310) 2025-12-31 16:20:51 +00:00
Patrick Honkonen
28c9637655
[deps]: Update Google ProtoBuf dependencies (#6308) 2025-12-30 19:01:39 +00:00
bw-ghapp[bot]
2d228b8496
Update SDK to 2.0.0-4254-6c954013 (#6218)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-12-30 18:12:39 +00:00
bw-ghapp[bot]
3bc538c1f8
Crowdin Pull (#6286)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-30 17:58:21 +00:00
Mick Letofsky
99717ab5d5
Review Code Triggered by labeled event (#6307) 2025-12-30 16:56:06 +00:00
Álison Fernandes
d98e459129
[PM-14880] Add pull-request trigger to PR Labeling workflow and address test findings (#6305) 2025-12-30 14:39:00 +00:00
Álison Fernandes
ebed1bd3cd
[PM-14880] Label updates to fido2 privileged apps lists (#6304) 2025-12-29 21:00:16 +00:00
Álison Fernandes
f4e23e85d2
[PM-14880] ci: Update labels of automated PRs; set labels for PRs created by the crowdin-pull.yml workflow (#6303) 2025-12-29 20:17:14 +00:00
Álison Fernandes
474acc05a6
[PM-14880] ci: Adds categories for automated release notes (#6302) 2025-12-29 20:16:01 +00:00
aj-rosado
87faba6824
Updated sdk to a version that fixes the password protected export issues (1.0.0-4328-km-fix-cherry-pick) (#6300) 2025-12-29 15:29:22 +00:00
renovate[bot]
89fb9c92d3
[deps]: Lock file maintenance (#6292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:45 +00:00
renovate[bot]
77a58f344d
[deps]: Update actions/upload-artifact action to v6 (#6290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:08 +00:00
renovate[bot]
dda32075d0
[deps]: Update actions/checkout action to v6 (#6289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:16:08 +00:00
renovate[bot]
038931312d
[deps]: Update actions/cache action to v5 (#6288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:15:11 +00:00
Patrick Honkonen
7cd0e2c176
PM-29843: Record item org migration events (#6275) 2025-12-29 14:18:10 +00:00
434 changed files with 25360 additions and 4531 deletions

View File

@ -1,7 +1,7 @@
---
name: reviewing-changes
version: 3.0.0
description: Android-specific code review workflow additions for Bitwarden Android. Provides change type refinements, checklist loading, and reference material organization. Complements bitwarden-code-reviewer agent's base review standards.
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 - Android Additions

View File

@ -11,6 +11,7 @@ Quick reference for Bitwarden Android architectural patterns during code reviews
- [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)
@ -210,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
```
@ -299,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

@ -0,0 +1,44 @@
# Testing Android Code Skill
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
## Purpose
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
## When This Skill Activates
The skill automatically loads when you ask questions like:
- "How do I test this ViewModel?"
- "Why is my Bitwarden test failing?"
- "Write tests for this repository"
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
## What's Included
| File | Purpose |
|------|---------|
| `SKILL.md` | Core testing patterns and base class locations |
| `references/test-base-classes.md` | Detailed base class documentation |
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
| `examples/repository-test-example.md` | Complete repository test with mocks |
## Patterns Covered
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
3. **BaseServiceTest** - MockWebServer setup for network testing
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
## Quick Start
For comprehensive architecture and testing philosophy, see:
- `docs/ARCHITECTURE.md`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

22
.github/label-pr.json vendored
View File

@ -1,15 +1,14 @@
{
"catch_all_label": "t:misc",
"title_patterns": {
"t:new-feature": ["feat", "feature"],
"t:enhancement": ["enhancement", "enh", "impr"],
"t:feature": ["feat", "feature", "tool"],
"t:bug": ["fix", "bug", "bugfix"],
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
"t:docs": ["docs"],
"t:ci": ["ci", "build", "chore(ci)"],
"t:deps": ["deps"],
"t:breaking-change": ["breaking", "breaking-change"],
"t:misc": ["misc"]
"t:misc": ["misc"],
"t:llm": ["llm"]
},
"path_patterns": {
"app:shared": [
@ -29,12 +28,21 @@
"app:authenticator": [
"authenticator/"
],
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json",
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
".claude/",
"detekt-config.yml"
],
"t:docs": [
@ -43,8 +51,8 @@
"t:deps": [
"gradle/"
],
"t:misc": [
"keystore/"
"t:llm": [
".claude/"
]
}
}

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

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

15
.github/renovate.json vendored
View File

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

View File

@ -4,21 +4,22 @@
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
Usage:
python label-pr.py <pr-number> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
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 -a
python label-pr.py 1234 --replace
python label-pr.py 1234 -r -d
python label-pr.py 1234 --config custom-config.json
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
@ -42,9 +43,6 @@ def load_config_json(config_file: str) -> dict:
print(f"✅ Loaded config from: {config_file}")
valid_config = True
if not config.get("catch_all_label"):
print("❌ Missing 'catch_all_label' in config file")
valid_config = False
if not config.get("title_patterns"):
print("❌ Missing 'title_patterns' in config file")
valid_config = False
@ -131,7 +129,7 @@ def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
labels_to_apply.remove("app:shared")
if not labels_to_apply:
print("::warning::No matching file paths found, no labels applied.")
print("::notice::No matching file paths found.")
return list(labels_to_apply)
@ -151,10 +149,31 @@ def label_title(pr_title: str, title_patterns: dict) -> list[str]:
break
if not labels_to_apply:
print("::warning::No matching title patterns found, no labels applied.")
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(
@ -165,6 +184,11 @@ def parse_args():
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",
@ -194,7 +218,6 @@ def parse_args():
def main():
args = parse_args()
config = load_config_json(args.config)
CATCH_ALL_LABEL = config["catch_all_label"]
LABEL_TITLE_PATTERNS = config["title_patterns"]
LABEL_PATH_PATTERNS = config["path_patterns"]
@ -216,21 +239,23 @@ def main():
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
all_labels = set(filepath_labels + title_labels)
if not any(label.startswith("t:") for label in all_labels):
all_labels.add(CATCH_ALL_LABEL)
if all_labels:
print("--------------------------------")
labels_str = ', '.join(sorted(all_labels))
if mode == "add":
print(f"🏷️ Adding labels: {labels_str}")
print(f"::notice::🏷️ Adding labels: {labels_str}")
if not args.dry_run:
gh_add_labels(pr_number, list(all_labels))
else:
print(f"🏷️ Replacing labels with: {labels_str}")
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(" No matching patterns found, no labels applied.")
print("::warning::No matching patterns found, no labels applied.")
print("✅ Done")

View File

@ -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
@ -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
@ -171,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
@ -182,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
@ -196,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"
@ -205,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
@ -215,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
@ -281,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
@ -309,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: |

View File

@ -49,7 +49,7 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@ -57,7 +57,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
@ -67,7 +67,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
@ -115,7 +115,7 @@ jobs:
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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
@ -127,7 +127,7 @@ jobs:
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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

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
@ -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
@ -110,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
@ -184,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
@ -201,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
@ -211,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
@ -295,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
@ -366,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: |
@ -419,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: |
@ -429,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
@ -488,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
@ -505,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
@ -515,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
@ -575,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
@ -587,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
@ -606,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

@ -96,4 +96,4 @@ jobs:
--base main \
--head "$BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci"
--label "t:deps"

View File

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

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

View File

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

View File

@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
permissions: {}

View File

@ -1,6 +1,8 @@
name: SDLC / Label PR by Files
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:
@ -19,6 +21,9 @@ on:
type: boolean
default: false
env:
_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
jobs:
label-pr:
name: Label PR by Changed Files
@ -37,7 +42,6 @@ jobs:
id: label-mode
env:
GH_TOKEN: ${{ github.token }}
_PR_NUMBER: ${{ inputs.pr-number }}
_PR_USER: ${{ github.event.pull_request.user.login }}
_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: |
@ -59,7 +63,7 @@ jobs:
exit 0
fi
if [ "$_PR_USER" = "renovate[bot]" ] || [ "$_PR_USER" = "bw-ghapp[bot]" ]; then
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
exit 0
@ -71,10 +75,16 @@ jobs:
- name: Label PR based on changed files
env:
GH_TOKEN: ${{ github.token }}
_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
_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: |
echo "🔍 Labeling PR #$_PR_NUMBER with mode: $_LABEL_MODE and dry-run: $_DRY_RUN"
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_LABEL_MODE" "$_DRY_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

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

View File

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

@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1190.0)
aws-sdk-core (3.239.2)
aws-partitions (1.1211.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,25 +17,25 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.206.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.213.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)
@ -62,7 +62,7 @@ GEM
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)
@ -169,23 +169,23 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.17.1)
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.18.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 (7.0.0)
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)

View File

@ -260,6 +260,8 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.bumptech.glide.okhttp)
ksp(libs.bumptech.glide.compiler)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
@ -299,16 +301,16 @@ dependencies {
testImplementation(libs.square.turbine)
}
tasks {
withType<Test> {
useJUnitPlatform()
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
@Suppress("MagicNumber")
forkEvery = 100
maxHeapSize = "2g"
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
afterEvaluate {

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

@ -84,15 +84,6 @@
<data android:host="*.bitwarden.eu" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -120,6 +111,35 @@
</intent-filter>
</activity>
<!-- Credential Provider Activity for handling passkey and password credential operations.
This activity is NOT exported to protect against external apps attempting to extract
vault credentials by sending malicious intents. Only our own PendingIntents can
launch this activity.
This is a transparent trampoline activity that launches MainActivity for credential
operations and forwards results back to the Credential Manager framework.
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
must remain in the back stack to receive the ActivityResult callback from
MainActivity. -->
<activity
android:name=".CredentialProviderActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".AccessibilityActivity"
android:exported="false"
@ -140,6 +160,19 @@
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View File

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

View File

@ -815,6 +815,18 @@
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.zoho.primeum.stable",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
}
]
}
}
]
}

View File

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

View File

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

View File

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

View File

@ -12,9 +12,11 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -120,6 +122,8 @@ class MainActivity : AppCompatActivity() {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Both root navigation and debug menu exist at this top level.
// The debug menu can appear on top of the rest of the app without

View File

@ -26,11 +26,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
@ -42,6 +39,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@ -80,7 +78,7 @@ class MainViewModel @Inject constructor(
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val shareManager: ShareManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
@ -314,11 +312,9 @@ class MainViewModel @Inject constructor(
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
credentialProviderRequestManager.getPendingCredentialRequest()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@ -376,59 +372,6 @@ class MainViewModel @Inject constructor(
)
}
createCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
createCredentialRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = createCredentialRequest,
)
// Switch accounts if the selected user is not the active user.
if (authRepository.activeUserId != null &&
authRepository.activeUserId != createCredentialRequest.userId
) {
authRepository.switchAccount(createCredentialRequest.userId)
}
}
fido2AssertCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
fido2AssertCredentialRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = fido2AssertCredentialRequest,
)
}
providerGetPasswordRequest != null -> {
// Set the user's verification status when a new GetPassword request is
// received to force explicit verification if the user's vault is
// unlocked when the request is received.
bitwardenCredentialManager.isUserVerified =
providerGetPasswordRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = providerGetPasswordRequest,
)
}
getCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = getCredentialsRequest,
)
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@ -452,6 +395,47 @@ class MainViewModel @Inject constructor(
),
)
}
credentialProviderRequest != null -> {
handleCredentialRequest(credentialProviderRequest)
}
}
}
/**
* Handles a credential request relayed from [CredentialProviderActivity] via
* [CredentialProviderRequestManager].
*
* This method converts the [CredentialProviderRequest] into the appropriate
* [SpecialCircumstance] for routing by [RootNavViewModel]. The credential data is trusted
* because it was set by our own [CredentialProviderActivity] through the internal manager,
* not parsed from intent extras.
*/
private fun handleCredentialRequest(request: CredentialProviderRequest) {
specialCircumstanceManager.specialCircumstance = when (request) {
is CredentialProviderRequest.CreateCredential -> {
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = request.request,
)
}
is CredentialProviderRequest.Fido2Assertion -> {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = request.request,
)
}
is CredentialProviderRequest.GetPassword -> {
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = request.request,
)
}
is CredentialProviderRequest.GetCredentials -> {
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = request.request,
)
}
}
}

View File

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

@ -12,7 +12,7 @@ sealed class CreateAuthRequestResult {
) : CreateAuthRequestResult()
/**
* Models the data returned when a auth request has been approved.
* Models the data returned when an auth request has been approved.
*/
data class Success(
val authRequest: AuthRequest,
@ -21,7 +21,7 @@ sealed class CreateAuthRequestResult {
) : CreateAuthRequestResult()
/**
* There was a generic error getting the user's auth requests.
* There was a generic error creating the auth request.
*/
data class Error(
val error: Throwable,

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
@ -17,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
@ -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
@ -43,7 +44,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing an modifying authentication state.
* Provides an API for observing and modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository :
@ -125,7 +126,7 @@ interface AuthRepository :
/**
* The organization for the active user.
*/
val organizations: List<SyncResponseJson.Profile.Organization>
val organizations: List<Organization>
/**
* Whether or not the welcome carousel should be displayed, based on the feature flag and
@ -282,7 +283,7 @@ interface AuthRepository :
): PasswordHintResult
/**
* Removes the users password from the account. This used used when migrating from master
* Removes the users password from the account. This is used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
@ -359,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
@ -384,7 +385,7 @@ interface AuthRepository :
): SendVerificationEmailResult
/**
* Validates the given [token] for the given [email]. Part of th new account registration flow.
* Validates the given [token] for the given [email]. Part of the new account registration flow.
*/
suspend fun validateEmailToken(
email: String,
@ -402,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.manager.toast.ToastManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
@ -13,6 +16,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
@ -44,6 +48,7 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.ui.platform.resource.BitwardenString
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.datasource.disk.model.AccountTokensJson
@ -69,6 +74,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
@ -78,6 +84,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
@ -92,6 +99,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.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -113,6 +121,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
@ -165,6 +174,7 @@ class AuthRepositoryImpl(
private val policyManager: PolicyManager,
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
@ -283,8 +293,11 @@ class AuthRepositoryImpl(
?.profile
?.forcePasswordResetReason
override val organizations: List<SyncResponseJson.Profile.Organization>
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
.orEmpty()
.toOrganizations()
override val showWelcomeCarousel: Boolean
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
@ -457,42 +470,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(
@ -501,6 +504,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,
@ -517,6 +551,7 @@ class AuthRepositoryImpl(
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@ -524,10 +559,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),
@ -945,8 +983,8 @@ class AuthRepositoryImpl(
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error(
@ -1008,9 +1046,10 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
}
val userId = activeAccount.profile.userId
return vaultSdkSource
.updatePassword(
userId = activeAccount.profile.userId,
userId = userId,
newPassword = newPassword,
)
.flatMap { updatePasswordResponse ->
@ -1036,14 +1075,15 @@ class AuthRepositoryImpl(
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = activeAccount.profile.userId,
userId = userId,
passwordHash = passwordHash,
)
}
toastManager.show(BitwardenString.updated_master_password)
// Log out the user after successful password reset.
// This clears all user state including forcePasswordResetReason.
logout(reason = LogoutReason.PasswordReset)
logout(reason = LogoutReason.PasswordReset, userId = userId)
// Return the success.
ResetPasswordResult.Success
@ -1290,7 +1330,7 @@ class AuthRepositoryImpl(
}
}
override suspend fun validatePin(pin: String): ValidatePinResult {
override suspend fun validatePinUserKey(pin: String): ValidatePinResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
@ -1299,13 +1339,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) },
@ -1387,6 +1427,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,
@ -1809,14 +1857,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(
@ -1837,11 +1894,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,
@ -1897,10 +1964,19 @@ class AuthRepositoryImpl(
)
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,
)
}
@ -1908,6 +1984,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,
@ -1920,10 +1997,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
@ -1953,9 +2039,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
@ -1965,6 +2060,7 @@ class AuthRepositoryImpl(
profile = profile,
privateKey = privateKey,
securityState = null,
signedPublicKey = null,
signingKey = null,
)
}
@ -1980,6 +2076,7 @@ class AuthRepositoryImpl(
profile: AccountJson.Profile,
privateKey: String,
securityState: String?,
signedPublicKey: String?,
signingKey: String?,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
@ -1997,10 +2094,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),
@ -2026,10 +2126,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,
@ -2047,20 +2150,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

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.DevicesService
@ -71,6 +72,7 @@ object AuthRepositoryModule {
logsManager: LogsManager,
userStateManager: UserStateManager,
kdfManager: KdfManager,
toastManager: ToastManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@ -97,6 +99,7 @@ object AuthRepositoryModule {
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
toastManager = toastManager,
)
@Provides

View File

@ -14,14 +14,16 @@ import com.bitwarden.network.model.OrganizationType
* @property keyConnectorUrl The key connector domain (if applicable).
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
* @property limitItemDeletion Indicates that the organization limits item deletion.
* @property shouldUseEvents Indicates if the organization uses tracking events.
*/
data class Organization(
val id: String,
val name: String?,
val name: String,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean = false,
val limitItemDeletion: Boolean,
val shouldUseEvents: Boolean,
)

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

@ -9,7 +9,7 @@ data class UserAccountTokens(
val refreshToken: String?,
) {
/**
* Returns `true` if the user is logged in, `false otherwise.
* Returns `true` if the user is logged in, `false` otherwise.
*/
val isLoggedIn: Boolean get() = accessToken != null
}

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

@ -13,26 +13,30 @@ private val JSON = Json {
}
/**
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
* [SyncResponseJson.Profile.Organization.name] is not present.
*/
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
Organization(
id = this.id,
name = this.name,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
)
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
this.name?.let {
Organization(
id = this.id,
name = it,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
shouldUseEvents = this.shouldUseEvents,
)
}
/**
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [Organization]s.
*/
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
this.map { it.toOrganization() }
this.mapNotNull { it.toOrganization() }
/**
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.

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

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

@ -54,7 +54,7 @@ val CipherView.isActiveWithFido2Credentials: Boolean
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())
/**
* Returns true when the cipher is not deleted and contains at least one Pasword credential.
* Returns true when the cipher is not deleted and contains at least one Password credential.
*/
val CipherView.isActiveWithPasswordCredentials: Boolean
get() = deletedDate == null && !(login?.password.isNullOrEmpty())

View File

@ -14,6 +14,8 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
@ -145,4 +147,9 @@ object CredentialProviderModule {
@Singleton
fun providePasskeyAttestationOptionsSanitizer(): PasskeyAttestationOptionsSanitizer =
PasskeyAttestationOptionsSanitizerImpl
@Provides
@Singleton
fun provideCredentialProviderRequestManager(): CredentialProviderRequestManager =
CredentialProviderRequestManagerImpl()
}

View File

@ -50,6 +50,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
private const val DAL_ROUTE = ".well-known/assetlinks.json"
/**
* Primary implementation of [BitwardenCredentialManager].
*/
@ -123,7 +125,7 @@ class BitwardenCredentialManagerImpl(
.getSignatureFingerprintAsHexString()
.orEmpty(),
host = hostUrl,
assetLinkUrl = hostUrl,
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
),
)
}
@ -258,6 +260,7 @@ class BitwardenCredentialManagerImpl(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
userHandle = null,
)
.fold(
onSuccess = { it },
@ -315,7 +318,7 @@ class BitwardenCredentialManagerImpl(
packageName = callingAppInfo.packageName,
sha256CertFingerprint = signatureFingerprint,
host = host,
assetLinkUrl = host,
assetLinkUrl = host.toDigitalAssetLinkUrl(),
),
)
@ -427,6 +430,13 @@ class BitwardenCredentialManagerImpl(
?.relyingParty
?.id
?.prefixHttpsIfNecessaryOrNull()
private fun String.toDigitalAssetLinkUrl(): String =
when {
this.endsWith(DAL_ROUTE) -> this
this.endsWith("/") -> "$this$DAL_ROUTE"
else -> "$this/$DAL_ROUTE"
}
}
private const val MAX_AUTHENTICATION_ATTEMPTS = 5

View File

@ -23,6 +23,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(CREATE_PASSKEY_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -44,6 +45,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(GET_PASSKEY_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
@ -65,6 +67,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(UNLOCK_ACCOUNT_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -83,6 +86,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(CREATE_PASSWORD_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -103,6 +107,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(GET_PASSWORD_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
@ -116,6 +121,7 @@ class CredentialManagerPendingIntentManagerImpl(
}
}
private val CREDENTIAL_ACTIVITY_CLASS = com.x8bit.bitwarden.CredentialProviderActivity::class.java
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"

View File

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.credentials.manager
import com.x8bit.bitwarden.CredentialProviderActivity
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.MainViewModel
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
/**
* Manages pending credential provider requests, relaying them from [CredentialProviderActivity]
* to [MainActivity] via a pull-based pattern.
*
* This approach ensures credential data is never passed through intent extras to
* exported activities. [CredentialProviderActivity] sets the request, then [MainViewModel]
* retrieves it once when handling the incoming intent.
*/
interface CredentialProviderRequestManager {
/**
* Set a pending credential request.
*/
fun setPendingCredentialRequest(request: CredentialProviderRequest)
/**
* Get and clear the pending credential request. Returns null if no request is pending.
*/
fun getPendingCredentialRequest(): CredentialProviderRequest?
}

View File

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.credentials.manager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Singleton
/**
* Primary implementation of [CredentialProviderRequestManager].
*
* Uses an [AtomicReference] for thread-safe get-and-clear semantics, ensuring
* the pending request is only processed once.
*/
@Singleton
class CredentialProviderRequestManagerImpl : CredentialProviderRequestManager {
private val pendingRequest = AtomicReference<CredentialProviderRequest?>(null)
override fun setPendingCredentialRequest(request: CredentialProviderRequest) {
pendingRequest.set(request)
}
override fun getPendingCredentialRequest(): CredentialProviderRequest? {
return pendingRequest.getAndSet(null)
}
}

View File

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.data.credentials.manager.model
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
/**
* Represents a pending credential provider request to be processed by MainActivity.
*/
sealed class CredentialProviderRequest {
/**
* Request to create a new FIDO2 passkey credential.
*/
data class CreateCredential(
val request: CreateCredentialRequest,
) : CredentialProviderRequest()
/**
* Request to assert (authenticate with) an existing FIDO2 passkey.
*/
data class Fido2Assertion(
val request: Fido2CredentialAssertionRequest,
) : CredentialProviderRequest()
/**
* Request to retrieve a password credential.
*/
data class GetPassword(
val request: ProviderGetPasswordCredentialRequest,
) : CredentialProviderRequest()
/**
* Request to get available credentials (BeginGetCredential flow).
*/
data class GetCredentials(
val request: GetCredentialsRequest,
) : CredentialProviderRequest()
}

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

@ -105,6 +105,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
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,7 +5,7 @@ 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.model.FlightRecorderDataSet
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@ -47,9 +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 INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
/**
* Primary implementation of [SettingsDiskSource].
@ -58,8 +59,10 @@ private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogRe
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)
@ -86,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?>()
@ -214,20 +218,6 @@ 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) {
@ -255,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,
@ -594,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

@ -12,8 +12,6 @@ import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.dsl.credentialExchangeRegistry
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriter
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.EventService
@ -106,22 +104,6 @@ object PlatformManagerModule {
application: Application,
): AppStateManager = AppStateManagerImpl(application = application)
@Provides
@Singleton
fun provideFlightRecorderManager(
@ApplicationContext context: Context,
clock: Clock,
dispatcherManager: DispatcherManager,
settingsDiskSource: SettingsDiskSource,
flightRecorderWriter: FlightRecorderWriter,
): FlightRecorderManager = FlightRecorderManager.create(
context = context,
clock = clock,
dispatcherManager = dispatcherManager,
flightRecorderDiskSource = 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,6 +125,7 @@ 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
}
@ -119,8 +134,10 @@ sealed class OrganizationEvent {
* 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 object ItemOrganizationAccepted : OrganizationEvent() {
override val cipherId: String? = null
data class ItemOrganizationAccepted(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
}
@ -129,8 +146,10 @@ sealed class OrganizationEvent {
* Tracks when a user chooses to leave an organization instead of migrating their personal
* ciphers to their organization's My Items folder.
*/
data object ItemOrganizationDeclined : OrganizationEvent() {
override val cipherId: String? = null
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

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

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

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

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

@ -28,6 +28,7 @@ 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.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
@ -35,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
@ -161,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())
@ -613,7 +685,7 @@ class CipherManagerImpl(
}
}
private suspend fun migrateAttachments(
override suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView> {

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
@ -61,12 +62,10 @@ interface VaultLockManager {
*/
@Suppress("LongParameterList")
suspend fun unlockVault(
accountCryptographicState: WrappedAccountCryptographicState,
userId: String,
email: String,
kdf: Kdf,
privateKey: String,
signingKey: String?,
securityState: String?,
initUserCryptoMethod: InitUserCryptoMethod,
organizationKeys: Map<String, String>?,
): VaultUnlockResult

View File

@ -7,6 +7,7 @@ import android.content.IntentFilter
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
@ -39,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
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.logTag
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@ -171,12 +173,10 @@ class VaultLockManagerImpl(
@Suppress("LongMethod")
override suspend fun unlockVault(
accountCryptographicState: WrappedAccountCryptographicState,
userId: String,
email: String,
kdf: Kdf,
privateKey: String,
signingKey: String?,
securityState: String?,
initUserCryptoMethod: InitUserCryptoMethod,
organizationKeys: Map<String, String>?,
): VaultUnlockResult = withContext(context = NonCancellable) {
@ -187,13 +187,11 @@ class VaultLockManagerImpl(
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
accountCryptographicState = accountCryptographicState,
kdfParams = kdf,
email = email,
privateKey = privateKey,
method = initUserCryptoMethod,
userId = userId,
signingKey = signingKey,
securityState = securityState,
),
)
.flatMap { result ->
@ -683,14 +681,18 @@ class VaultLockManagerImpl(
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = initUserCryptoMethod,
organizationKeys = organizationKeys,
)
@ -708,7 +710,6 @@ class VaultLockManagerImpl(
is InitUserCryptoMethod.DecryptedKey,
is InitUserCryptoMethod.DeviceKey,
is InitUserCryptoMethod.KeyConnector,
is InitUserCryptoMethod.Password,
is InitUserCryptoMethod.Pin,
is InitUserCryptoMethod.PinEnvelope,
-> return

View File

@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.vault.manager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the migration of personal vault items to organization collections.
* This interface provides a way to check if migration is needed and track migration state.
*
* The manager reactively observes vault cipher data and automatically updates the migration state
* when conditions change (e.g., after sync, after vault unlock, policy changes).
*/
interface VaultMigrationManager {
/**
* Flow that emits when conditions are met for the user to migrate their personal vault.
* Automatically updated when cipher data, policies, or feature flags change.
*/
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
/**
* Migrates all personal vault items to the specified organization.
*
* @param userId The ID of the user performing the migration.
* @param organizationId The ID of the organization to migrate items to.
* @return Result indicating success or failure of the migration operation.
*/
suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult
/**
* Clears the migration state, setting it to [VaultMigrationData.NoMigrationRequired].
* This should be called when the user declines migration or leaves the organization.
*/
fun clearMigrationState()
}

View File

@ -0,0 +1,325 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionType
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import com.bitwarden.network.service.CiphersService
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
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.GetCipherResult
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.updateFromMiniResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of [VaultMigrationManager].
*
* Reactively observes vault cipher data and automatically updates migration state when:
* - Vault is unlocked
* - Sync has occurred at least once
* - Cipher data changes
* - Network connectivity changes
*/
@Suppress("LongParameterList")
class VaultMigrationManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultRepository: VaultRepository,
private val vaultSdkSource: VaultSdkSource,
private val ciphersService: CiphersService,
private val settingsDiskSource: SettingsDiskSource,
private val policyManager: PolicyManager,
private val featureFlagManager: FeatureFlagManager,
private val connectionManager: NetworkConnectionManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
) : VaultMigrationManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableVaultMigrationDataStateFlow =
MutableStateFlow<VaultMigrationData>(value = VaultMigrationData.NoMigrationRequired)
override val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
get() = mutableVaultMigrationDataStateFlow.asStateFlow()
init {
// Observe cipher data changes and automatically verify migration state
mutableVaultMigrationDataStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId ->
observeCipherDataAndUpdateMigrationState(userId = activeUserId)
}
.launchIn(unconfinedScope)
}
/**
* Observes cipher data, sync state, and network connectivity for the given user and updates
* migration state when changes occur. Only emits updates after the user has synced at least
* once to ensure data freshness.
*
* Uses optimized [VaultDiskSource.hasPersonalCiphersFlow] query that checks only the
* indexed organizationId column without loading full cipher JSON data.
*
* Combines cipher data with [SettingsDiskSource.getLastSyncTimeFlow] to handle multi-account
* scenarios where lastSyncTime may be cleared without clearing cipher data. This ensures
* migration state updates when sync completes, not just when cipher data changes.
*
* Also combines with [NetworkConnectionManager.isNetworkConnectedFlow] to ensure migration
* state updates reactively when network connectivity changes.
*/
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
combine(
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
settingsDiskSource.getLastSyncTimeFlow(userId = userId),
connectionManager.isNetworkConnectedFlow,
) { hasPersonalCiphers, lastSyncTime, isNetworkConnected ->
// Only process after sync has occurred at least once
lastSyncTime ?: return@combine null
hasPersonalCiphers to isNetworkConnected
}
.filterNotNull()
.onEach { (hasPersonalCiphers, isNetworkConnected) ->
verifyAndUpdateMigrationState(
userId = userId,
hasPersonalCiphers = hasPersonalCiphers,
isNetworkConnected = isNetworkConnected,
)
}
/**
* Verifies if the user should migrate their personal vault to organization collections
* based on active policies, feature flags, network connectivity, and whether they have
* personal ciphers.
*
* @param userId The ID of the user to check for migration.
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
*/
private fun verifyAndUpdateMigrationState(
userId: String,
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
) {
mutableVaultMigrationDataStateFlow.update {
if (!shouldMigrateVault(
hasPersonalCiphers = hasPersonalCiphers,
isNetworkConnected = isNetworkConnected,
)
) {
return@update VaultMigrationData.NoMigrationRequired
}
val orgId = policyManager.getPersonalOwnershipPolicyOrganizationId()
?: return@update VaultMigrationData.NoMigrationRequired
val orgName = authDiskSource
.getOrganizations(userId = userId)
?.firstOrNull { it.id == orgId }
?.name
?: return@update VaultMigrationData.NoMigrationRequired
VaultMigrationData.MigrationRequired(
organizationId = orgId,
organizationName = orgName,
)
}
}
/**
* Checks if the user should migrate their vault based on policies, feature flags,
* network connectivity, and whether they have personal items.
*
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
* @return true if migration conditions are met, false otherwise.
*/
private fun shouldMigrateVault(
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
): Boolean =
policyManager
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.any() &&
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers
override suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult {
val vaultData = vaultRepository.vaultDataStateFlow.value.data
?: return MigratePersonalVaultResult.Failure(
IllegalStateException("Vault data not available"),
)
val defaultUserCollection = getDefaultUserCollection(vaultData, organizationId)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
val personalCiphers = getPersonalCipherViews(vaultData)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
if (personalCiphers.isEmpty()) {
clearMigrationState()
return MigratePersonalVaultResult.Success
}
val cipherIds = personalCiphers.mapNotNull { it.id }
val encryptedCiphers = vaultDiskSource.getSelectedCiphers(
userId = userId,
cipherIds = cipherIds,
)
val encryptedCiphersMap = encryptedCiphers.associateBy { it.id }
val processedCipherViews = migrateAttachments(userId, personalCiphers)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
encryptAndShareCiphers(
userId = userId,
organizationId = organizationId,
processedCipherViews = processedCipherViews,
encryptedCiphersMap = encryptedCiphersMap,
collectionIds = listOfNotNull(defaultUserCollection.id),
).getOrElse { return MigratePersonalVaultResult.Failure(it) }
clearMigrationState()
return MigratePersonalVaultResult.Success
}
override fun clearMigrationState() {
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
}
private fun getDefaultUserCollection(
vaultData: VaultData,
organizationId: String,
): Result<CollectionView> {
val collection = vaultData.collectionViewList.find {
it.type == CollectionType.DEFAULT_USER_COLLECTION && it.organizationId == organizationId
}
return collection?.asSuccess()
?: IllegalStateException("Default user collection not found for organization")
.asFailure()
}
private suspend fun getPersonalCipherViews(
vaultData: VaultData,
): Result<List<CipherView>> = runCatching {
vaultData.decryptCipherListResult.successes
.filter { it.organizationId == null }
.mapNotNull { cipherListView ->
cipherListView.id?.let { cipherId ->
vaultRepository
.getCipher(cipherId = cipherId)
.toCipherViewOrFailure()
?.getOrElse { error ->
return error.asFailure()
}
}
}
}
private suspend fun migrateAttachments(
userId: String,
personalCiphers: List<CipherView>,
): Result<List<CipherView>> = runCatching {
personalCiphers.map { cipherView ->
vaultRepository
.migrateAttachments(userId = userId, cipherView = cipherView)
.getOrElse { error ->
return error.asFailure()
}
}
}
private suspend fun encryptAndShareCiphers(
userId: String,
organizationId: String,
processedCipherViews: List<CipherView>,
encryptedCiphersMap: Map<String, SyncResponseJson.Cipher>,
collectionIds: List<String>,
): Result<Unit> {
return vaultSdkSource
.bulkMoveToOrganization(
userId = userId,
organizationId = organizationId,
cipherViews = processedCipherViews,
collectionIds = collectionIds,
)
.map { encryptionContexts ->
encryptionContexts.mapNotNull { context ->
context.cipher.id?.let { cipherId ->
context
.toEncryptedNetworkCipher()
.toCipherWithIdJsonRequest(id = cipherId)
}
}
}
.flatMap { cipherRequests ->
ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = cipherRequests,
collectionIds = collectionIds,
),
)
}
.map { bulkShareResponse ->
bulkShareResponse.cipherMiniResponse.forEach { miniResponse ->
encryptedCiphersMap[miniResponse.id]?.let {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.updateFromMiniResponse(
miniResponse = miniResponse,
collectionIds = collectionIds,
),
)
}
}
}
}
private fun GetCipherResult.toCipherViewOrFailure(): Result<CipherView>? =
when (this) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found for vault migration.")
null
}
is GetCipherResult.Failure -> {
Timber.e(this.error, "Failed to decrypt cipher for vault migration.")
this.error.asFailure()
}
is GetCipherResult.Success -> this.cipherView.asSuccess()
}
}

View File

@ -17,9 +17,11 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -37,8 +39,11 @@ import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -55,6 +60,34 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideVaultMigrationManager(
authDiskSource: AuthDiskSource,
vaultDiskSource: VaultDiskSource,
vaultRepository: VaultRepository,
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
settingsDiskSource: SettingsDiskSource,
vaultLockManager: VaultLockManager,
policyManager: PolicyManager,
featureFlagManager: FeatureFlagManager,
connectionManager: NetworkConnectionManager,
dispatcherManager: DispatcherManager,
): VaultMigrationManager = VaultMigrationManagerImpl(
authDiskSource = authDiskSource,
vaultDiskSource = vaultDiskSource,
vaultRepository = vaultRepository,
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
featureFlagManager = featureFlagManager,
connectionManager = connectionManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideCipherManager(

View File

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.manager.model
/**
* Represents vault migration state with organization metadata.
*/
sealed class VaultMigrationData {
/**
* User should migrate personal vault items to the specified organization.
*/
data class MigrationRequired(
val organizationId: String,
val organizationName: String,
) : VaultMigrationData()
/**
* No migration required.
*/
data object NoMigrationRequired : VaultMigrationData()
}

View File

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.vault.repository
import com.bitwarden.core.DateTime
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
@ -24,6 +23,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.time.Instant
import javax.crypto.Cipher
/**
@ -92,6 +92,7 @@ interface VaultRepository :
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
userHandle: String?,
): Result<List<Fido2CredentialAutofillView>>
/**
@ -134,7 +135,7 @@ interface VaultRepository :
/**
* Attempt to get the verification code and the period.
*/
suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult
suspend fun generateTotp(cipherId: String, time: Instant): GenerateTotpResult
/**
* Attempt to get the user's vault data for export.
@ -166,11 +167,4 @@ interface VaultRepository :
* `null` if the item cannot be found.
*/
fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>>
/**
* Checks if there are any personal vault items (items without an organization ID) in the vault.
*
* @return `true` if there are personal vault items, `false` otherwise.
*/
fun hasPersonalVaultItems(): Boolean
}

View File

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.vault.repository
import com.bitwarden.core.DateTime
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
@ -41,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
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.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
@ -62,6 +62,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import java.security.GeneralSecurityException
import java.time.Instant
import javax.crypto.Cipher
/**
@ -253,12 +254,14 @@ class VaultRepositoryImpl(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
userHandle: String?,
): Result<List<Fido2CredentialAutofillView>> =
vaultSdkSource
.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
userHandle = userHandle,
)
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
@ -405,7 +408,7 @@ class VaultRepositoryImpl(
override suspend fun generateTotp(
cipherId: String,
time: DateTime,
time: Instant,
): GenerateTotpResult {
val userId = activeUserId
?: return GenerateTotpResult.Error(error = NoActiveUserException())
@ -537,22 +540,21 @@ class VaultRepositoryImpl(
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource
.getOrganizationKeys(userId = userId)
return vaultLockManager.unlockVault(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = initUserCryptoMethod,
organizationKeys = organizationKeys,
)
}
override fun hasPersonalVaultItems(): Boolean {
val vaultData = vaultSyncManager.vaultDataStateFlow.value.data ?: return false
return vaultData.decryptCipherListResult.successes.any { it.organizationId.isNullOrEmpty() }
}
}

View File

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of archiving a cipher.
*/
sealed class ArchiveCipherResult {
/**
* Cipher archived successfully.
*/
data object Success : ArchiveCipherResult()
/**
* Generic error while archiving a cipher.
*/
data class Error(val error: Throwable) : ArchiveCipherResult()
}

View File

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of migrating the personal vault.
*/
sealed class MigratePersonalVaultResult {
/**
* Personal vault migrated successfully.
*/
data object Success : MigratePersonalVaultResult()
/**
* Generic error while migrating personal vault
*/
data class Failure(val error: Throwable?) : MigratePersonalVaultResult()
}

View File

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of unarchiving a cipher.
*/
sealed class UnarchiveCipherResult {
/**
* Cipher unarchived successfully.
*/
data object Success : UnarchiveCipherResult()
/**
* Generic error while unarchiving a cipher.
*/
data class Error(val error: Throwable) : UnarchiveCipherResult()
}

View File

@ -15,9 +15,4 @@ val InitUserCryptoMethod.logTag: String
is InitUserCryptoMethod.Pin -> "Pin"
is InitUserCryptoMethod.PinEnvelope -> "Pin Envelope"
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
is InitUserCryptoMethod.Password -> {
// PM-27290: InitUserCryptoMethod.Password will be removed from the SDK in a future
// release. This else branch can be cleaned up afterwards.
throw IllegalArgumentException("Unsupported InitUserCryptoMethod: $this")
}
}

View File

@ -5,6 +5,7 @@ package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CipherRepromptTypeJson
import com.bitwarden.network.model.CipherTypeJson
import com.bitwarden.network.model.FieldTypeJson
@ -109,6 +110,32 @@ fun Cipher.toEncryptedNetworkCipherResponse(
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
* Updates a [SyncResponseJson.Cipher] with metadata from a
* [CipherMiniResponseJson.CipherMiniResponse].
* This is useful for updating local cipher data after bulk operations that return mini responses.
*
* @param miniResponse The mini response containing updated cipher metadata.
* @param collectionIds Optional list of collection IDs to update.
* If null, keeps existing collection IDs.
* @return A new [SyncResponseJson.Cipher] with updated fields from the mini response.
*/
fun SyncResponseJson.Cipher.updateFromMiniResponse(
miniResponse: CipherMiniResponseJson.CipherMiniResponse,
collectionIds: List<String>? = null,
): SyncResponseJson.Cipher = copy(
organizationId = miniResponse.organizationId,
collectionIds = collectionIds ?: this.collectionIds,
revisionDate = miniResponse.revisionDate,
key = miniResponse.key,
attachments = miniResponse.attachments,
archivedDate = miniResponse.archivedDate,
deletedDate = miniResponse.deletedDate,
reprompt = miniResponse.reprompt,
shouldOrganizationUseTotp = miniResponse.shouldOrganizationUseTotp,
type = miniResponse.type,
)
/**
* Converts a Bitwarden SDK [Card] object to a corresponding
* [SyncResponseJson.Cipher.Card] object.

View File

@ -4,6 +4,7 @@ import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparat
import com.bitwarden.network.model.SendJsonRequest
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.send.AuthType
import com.bitwarden.send.Send
import com.bitwarden.send.SendFile
import com.bitwarden.send.SendText
@ -92,6 +93,8 @@ fun SyncResponseJson.Send.toEncryptedSdkSend(): Send =
revisionDate = revisionDate.toInstant(),
deletionDate = deletionDate.toInstant(),
expirationDate = expirationDate?.toInstant(),
emails = null,
authType = AuthType.NONE,
)
/**

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