Compare commits

..

249 Commits

Author SHA1 Message Date
David Perez
6d71f0c5d6 🍒 Update Autofill to detect url bar webDomain for certain browsers (#6155) 2025-11-12 13:49:03 -06:00
aj-rosado
23e4b1163c 🍒 [PM-27902] Logout user after successful master password reset (#6137) 2025-11-07 17:14:48 +00:00
aj-rosado
317fd376a7 🍒 [PM-27806] Reverted changes to order of StorePolicies after sync (#6132) 2025-11-06 18:53:04 +00:00
David Perez
bc74337eae Add Push chevron to Block autofill button (#6102) 2025-10-31 17:37:04 +00:00
bw-ghapp[bot]
d86443c6dd Crowdin Pull (#6101)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-31 14:04:47 +00:00
aj-rosado
d07b119802 [PM-27120] cxp hide user account when remove individual export is enabled (#6089) 2025-10-31 10:08:24 +00:00
David Perez
dbf2e9f68a Update Readme compatibility docs (#6100) 2025-10-30 21:50:44 +00:00
David Perez
9ddfd376a9 Fix topAppBar flicker when text is long (#6098) 2025-10-30 20:32:13 +00:00
Patrick Honkonen
dd1dbd0b97 Update androidx.credentials to 1.6.0-beta03 (#6097) 2025-10-30 17:59:28 +00:00
renovate[bot]
f6be363e98 [deps]: Update com.google.devtools.ksp to v2.3.0 (#6080)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-10-30 14:48:30 +00:00
David Perez
600744538d Fix deprecation within the app (#6096) 2025-10-29 21:02:03 +00:00
David Perez
de33ba021b Update the Google Protobuf library (#6095) 2025-10-29 21:01:35 +00:00
David Perez
290f59441f Update Kotlin, ksp, and kover to the latest versions (#6094) 2025-10-29 19:27:06 +00:00
David Perez
94c51cacf9 Update Androidx dependencies (#6093) 2025-10-29 16:53:48 +00:00
Dev Sharma
6f27642a30 [PM-27589] [PM-27158] fix : Sub folders always show 0 items (#6092) 2025-10-29 15:54:15 +00:00
Dev Sharma
2ad3014da2 [PM-27516] [PM 27157] Custom text field edit multiline fix (#6088) 2025-10-29 15:44:44 +00:00
renovate[bot]
e6dc8e02f8 [deps]: Lock file maintenance (#6083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 21:34:02 +00:00
David Perez
c16d31fb33 PM-27494: Update custom vault timeout UI (#6085) 2025-10-27 21:13:36 +00:00
David Perez
43d7b84d0a PM-27136: Update Snackbar font when there is no header (#6086) 2025-10-27 19:53:31 +00:00
André Bispo
c0f8307361 [PM-26420] FlightRecorder vault unlock method (#6084) 2025-10-27 17:55:51 +00:00
mpbw2
064a98f86b [PM-22157] independent version names in build workflows (#6074) 2025-10-27 17:51:56 +00:00
David Perez
e3b111c383 PM-19302: Add support for a typed vault timeout policy (#6078) 2025-10-27 16:28:01 +00:00
Mick Letofsky
52304a266e Implement reusable Claude code review workflow (#6072)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-10-27 14:19:37 +00:00
David Perez
51c23ec464 Minor clean up for the Account Security Screen (#6076) 2025-10-24 15:55:50 +00:00
André Bispo
7d7951d4ca [PM-27176] Switch to using SDK's init crypto with MasterPasswordUnlock (#6073) 2025-10-24 13:56:44 +00:00
bw-ghapp[bot]
78b1676745 Crowdin Pull (#6077)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-24 13:20:49 +00:00
aj-rosado
be27c76bd3 [PM-27092] Changing screen capture flow from event based to state based on Authenticator (#6062) 2025-10-24 09:26:53 +00:00
David Perez
38bdda0a41 Create reusable supporting content composable (#6075) 2025-10-23 20:07:04 +00:00
David Perez
c61fec176a PM-27271: Update selection button disabled state (#6071) 2025-10-23 16:23:06 +00:00
Patrick Honkonen
bb11b17823 [PM-26810] Clear password input after successful OTP verification (#6070) 2025-10-23 13:09:29 +00:00
David Perez
562b48d689 Fix TopAppBar height for multiline titles (#6069) 2025-10-22 21:09:28 +00:00
Patrick Honkonen
c3496ca60f [PM-26810] Remove loading dialog flicker on vault data updates (#6068) 2025-10-22 20:30:48 +00:00
Nailik
a8f8450ec9 [PM-27088] fix unit test execution (#6048) 2025-10-22 20:27:59 +00:00
David Perez
47628a6da2 Remove night-mode icon variants where possible (#6066) 2025-10-22 20:06:13 +00:00
David Perez
5a540a3460 PM-27263: Add enum for Vault Timeout Policy actions (#6067) 2025-10-22 20:05:48 +00:00
David Perez
92cfce1224 PM-27202: Update ItemListingScreen layout for improved spacing (#6065) 2025-10-22 14:42:35 +00:00
David Perez
4597337500 PM-27210: Add dynamic color support to Authenticator (#6063) 2025-10-22 14:42:18 +00:00
aj-rosado
e610a7541d [PM-27001] Skip account selection only one exists on cxp flow (#6055) 2025-10-22 09:08:35 +00:00
David Perez
ae4b398258 PM-27153: Update copy in Authenticator app (#6061) 2025-10-21 16:06:08 +00:00
David Perez
0482f9eb4d Update drawable names with consistent prefixes (#6060) 2025-10-21 15:54:31 +00:00
André Bispo
9f4bd70c8d [PM-26420] Add flight recorder logs for vault unlock method and PIN migration (#6052) 2025-10-20 22:29:10 +00:00
David Perez
9874aad65a PM-27149: Update empty vault illustration (#6059) 2025-10-20 21:46:31 +00:00
David Perez
97bb93c18e PM-27136: Replace FirstTimeSyncSnackbarHost with BitwardenSnackbarHost (#6058) 2025-10-20 20:42:47 +00:00
Patrick Honkonen
31e7e05eda [PM-27130] Update alert (Snackbar) color to inverseSurface in dynamic color scheme (#6057) 2025-10-20 18:15:03 +00:00
André Bispo
afeeb494da [PM-23290] Migrate PIN unlock keys to PinProtectedUserKeyEnvelope (#6024) 2025-10-20 17:31:12 +00:00
aj-rosado
d5912a5dc3 [PM-26986] Hide select other account button if user has no other account (#6041) 2025-10-20 16:02:43 +00:00
bw-ghapp[bot]
13fa8a1ed0 Update SDK to 1.0.0-3436-2a00b727 (#6042)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-20 15:55:31 +00:00
David Perez
5a145ee163 Update OkHttp to latest version (#6054) 2025-10-20 15:35:09 +00:00
celenityy
74b9a12e19 [PM-27076] Add support for IronFox Nightly (#6046)
Signed-off-by: celenity <celenity@celenity.dev>
2025-10-17 13:34:29 +00:00
David Perez
71e830bb09 PM-26912: Update copy for authenticator security (#6045)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-10-17 13:27:19 +00:00
David Perez
8f3f1fa3ba PM-27071: Add overflow menu to authenticator search (#6044) 2025-10-17 12:58:17 +00:00
bw-ghapp[bot]
9bd35ccca5 Crowdin Pull (#6047)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-17 12:57:56 +00:00
Patrick Honkonen
74aa0a78ec [PM-26810] Add OTP support to VerifyPasswordScreen (#6034) 2025-10-16 21:02:52 +00:00
David Perez
ae3470c598 Fix flaky test (#6043) 2025-10-16 19:18:58 +00:00
André Bispo
a70b2172cb [PM-26736] Prevent logout notification on KDF change (#6038) 2025-10-16 18:54:56 +00:00
David Perez
714f7cfadc PM-27046: Add overflow to Authenticator (#6039) 2025-10-16 18:15:41 +00:00
Amy Galles
53d04375b1 Fix workflow name and permissions (#6040) 2025-10-16 18:06:30 +00:00
aj-rosado
3ace095b86 [PM-26909] Implement screen capture toggle authenticator (#6033) 2025-10-16 15:51:54 +00:00
David Perez
8a90d77fd7 Update Item listing and search screens to user immutable lists (#6037) 2025-10-16 15:43:52 +00:00
bw-ghapp[bot]
4b96007a77 Update SDK to 1.0.0-3430-fc75b903 (#6036)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-16 10:54:23 +00:00
David Perez
03df341a1e Consolidate the VaultVerificationCodeItems (#6035) 2025-10-15 20:43:30 +00:00
André Bispo
d966423087 [PM-23280] Use masterPasswordUnlock KDF settings on vault unlock (#6026) 2025-10-15 20:01:19 +00:00
Patrick Honkonen
f7cbcd21ec Expand supported credential types for import (#6030) 2025-10-15 13:49:20 +00:00
bw-ghapp[bot]
188ddf98f4 Update SDK to 1.0.0-3404-8b95ae6e (#6021)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-10-15 13:31:32 +00:00
aj-rosado
b8f4129691 [PM-26395] Hide "my items" collection when item is assigned to other collection (#6018) 2025-10-15 10:24:31 +00:00
bw-ghapp[bot]
b8482de96c Crowdin Pull (#6031)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-14 21:41:59 +00:00
Amy Galles
9e860008e8 [BRE-1194] temporarily enable hourly checks for github release (#5895)
Co-authored-by: Andy Pixley <3723676+pixman20@users.noreply.github.com>
2025-10-14 21:33:39 +00:00
Patrick Honkonen
af737b3f07 [PM-26803] Show empty state when no items are available for export (#6023) 2025-10-14 20:01:17 +00:00
David Perez
5b5176db40 PM-26910: Minor UI updates for the Authenticator (#6028) 2025-10-14 19:59:08 +00:00
David Perez
e7365b355f Common camera UI (#6027) 2025-10-14 19:47:25 +00:00
Patrick Honkonen
433b3b6fb0 Add optional buttons to BitwardenEmptyContent (#6022) 2025-10-14 15:17:35 +00:00
David Perez
318307c377 Add navigation chevron for Import Items button (#6020) 2025-10-13 20:48:41 +00:00
Patrick Honkonen
912eba14d6 [PM-26802] Update button text for Import items (#6019) 2025-10-13 18:19:49 +00:00
bw-ghapp[bot]
837dd27106 Update SDK to 1.0.0-3390-a0531e84 (#6013)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-13 17:55:39 +00:00
renovate[bot]
a75e938070 [deps]: Update gradle/actions action to v5 (#6010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 17:54:56 +00:00
Patrick Honkonen
054afab2cf [PM-26804] Clear password input after verification in VerifyPasswordViewModel (#6017) 2025-10-13 17:53:32 +00:00
bw-ghapp[bot]
c015c8aa43 Crowdin Pull (#6001)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-13 17:47:06 +00:00
David Perez
4161020e6c Fix plurals issue for Crowdin (#6016) 2025-10-13 16:02:51 +00:00
David Perez
5543bc6ab5 Add logging to UI module for Auth Tabs (#6015) 2025-10-13 15:52:58 +00:00
Patrick Honkonen
5cdee938bf Correct environment variable names in build workflow (#6008) 2025-10-13 15:18:21 +00:00
David Perez
62f76a4f8b Fix statusbar color when display is turned off (#6006) 2025-10-13 14:43:15 +00:00
renovate[bot]
7ea87505a4 [deps]: Lock file maintenance (#6011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 14:15:44 +00:00
David Perez
c6f132d5f7 PM-26575: Add AuthTab support for WebAuthN, Duo, and SSO (#6002) 2025-10-10 21:38:31 +00:00
David Perez
0604d15d7d PM-26560: Fix cross-origin autofill issues (#5977) 2025-10-10 21:06:59 +00:00
bw-ghapp[bot]
5706ca2ba3 Update SDK to 1.0.0-3367-cc36132b (#5981)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-10 19:34:37 +00:00
David Perez
de8c344b46 Update Compose BOM to v2025.10.00 (#6007) 2025-10-10 18:19:04 +00:00
David Perez
957460f403 Update JUnit and Mockk test dependencies (#6005) 2025-10-10 17:59:42 +00:00
Matt Andreko
2b88743bea Remove quotes in fastlane bundle commands (#6003) 2025-10-10 17:08:39 +00:00
David Perez
5243ed27d3 Update Firebase BOM and Google Services (#6004) 2025-10-10 15:35:13 +00:00
aj-rosado
a7bbb81b31 [PM-24258] Building a specific Fido2AttestationResponse to work with Binance (#5986) 2025-10-10 14:07:52 +00:00
Patrick Honkonen
2d2b740ae1 [#5997] Allow underscores in Block Autofill URI patterns (#6000) 2025-10-09 17:58:24 +00:00
Matt Andreko
81fa635430 Implement Zizmor workflow scanner (#5857) 2025-10-09 16:49:10 +00:00
David Perez
a1cb948257 [deps]: Update androidx.camera to v1.5.1 (#5999) 2025-10-09 16:03:58 +00:00
Patrick Honkonen
5bb7abbf5a Remove logging in CredentialExchangeCompletionManager (#5998) 2025-10-09 14:23:49 +00:00
André Bispo
d98ff6478f [PM-23278] Upgrade user KDF settings to minimums (#5955)
Co-authored-by: David Perez <david@livefront.com>
2025-10-09 07:49:22 +00:00
David Perez
44c373a354 Ensure the fab is dismissed when clicking on the lower portion of the content (#5996) 2025-10-08 22:06:02 +00:00
Patrick Honkonen
3d493bb9d0 [PM-26716] Validate credential exchange request (#5994) 2025-10-08 20:56:47 +00:00
Patrick Honkonen
07b8115d7a [PM-26718] Move Credential Exchange intent filter to main manifest (#5995) 2025-10-08 20:10:36 +00:00
David Perez
9ced8647a3 Update Crowdin plurals (#5991) 2025-10-08 20:09:47 +00:00
David Perez
b3c3365b5a [deps]: Update androidx.room to v2.8.2 (#5993) 2025-10-08 20:07:59 +00:00
David Perez
266c16958d [deps]: Update com.google.devtools.ksp to v2.2.20-2.0.4 (#5992) 2025-10-08 20:07:29 +00:00
David Perez
340b4f25f7 Add tests for ShareManager (#5990) 2025-10-08 19:48:04 +00:00
David Perez
572d3357ee Simplify the BitwardenExpandableFloatingActionButton (#5989) 2025-10-08 18:31:49 +00:00
Patrick Honkonen
3a4f1d719f [PM-26315] Register/unregister for CXP export based on feature flag (#5948) 2025-10-08 18:00:50 +00:00
David Perez
bebf94796c PM-20593: sync-org-keys notification should allow token to be refreshed on next request (#5988) 2025-10-08 15:32:39 +00:00
David Perez
10a92dd2a3 PM-26689: Separate share logic from IntentManager (#5987) 2025-10-08 15:15:19 +00:00
David Perez
d306813d1f The QrCodeScanScreen should always be in dark mode (#5983) 2025-10-07 20:31:44 +00:00
David Perez
97c4cd705b PM-25908: Do not use network error message from 401 (#5984) 2025-10-07 20:31:21 +00:00
Patrick Honkonen
9fee973563 Improve CXF message handling (#5982) 2025-10-07 18:28:07 +00:00
David Perez
202dd65229 PM-26579: Remove duplicated BitwardenScaffold from ItemListingScreen (#5978) 2025-10-07 17:04:32 +00:00
David Perez
7849bbbb0a PM-26594: Move the QrCodeAnalyzer to the UI module (#5980) 2025-10-07 17:04:16 +00:00
David Perez
cd9c7f98e7 PM-26358: Integrate the token auth logic with the SDK (#5967) 2025-10-07 16:49:57 +00:00
bw-ghapp[bot]
0c9530472f Update SDK to 1.0.0-3309-9574bd00 (#5979)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-07 13:40:19 +00:00
David Perez
2636a4f93a Update Authenticator UI to match Password Manager style (#5969) 2025-10-06 14:59:46 +00:00
bw-ghapp[bot]
ca474b272a Update SDK to 1.0.0-3293-ae9b8b52 (#5975)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-10-06 14:54:29 +00:00
Patrick Honkonen
acc9113f9a [PM-26355] Improve SelectAccountScreen state handling (#5965) 2025-10-02 21:05:08 +00:00
David Perez
2eb829a25b [deps]: Update org.sonarqube to v6.3.1.5724 (#5973) 2025-10-02 20:51:02 +00:00
Álison Fernandes
04a1d4118f Update renovate.json to exclude com.github.bumptech.glide from gradle-minor group (#5974) 2025-10-02 20:39:47 +00:00
David Perez
9f63cede11 Update UI elements for common use in Authenticator (#5971) 2025-10-02 18:37:17 +00:00
David Perez
a93037d63e PM-26445: Common Debug menu components (#5970) 2025-10-02 17:32:22 +00:00
Patrick Honkonen
4e57f306d3 [PM-26330] Correct owner data when individual vault is disabled (#5968) 2025-10-02 15:56:50 +00:00
André Bispo
1638a20bf0 [PM-23280] Save MasterPasswordUnlockData to local state (#5944) 2025-10-02 14:48:28 +00:00
bw-ghapp[bot]
874edfad69 Update SDK to 1.0.0-3194-9947387b (#5938)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Hinton <hinton@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-10-01 17:15:23 +00:00
David Perez
0469731fba Update Kover to v0.9.2 (#5966) 2025-10-01 17:08:54 +00:00
David Perez
0abfa5bb97 Update Androidx Camera to v1.5.0 (#5896) 2025-10-01 17:08:10 +00:00
aj-rosado
13e6728d46 [PM-17870] Always include clientExtensionResults in Fido2AttestationResponse (#5964) 2025-10-01 13:58:57 +00:00
David Perez
116bfd6351 PM-26312: Add browser integration help link (#5963) 2025-09-30 17:47:43 +00:00
David Perez
6ca8a39355 Update Guava to v33.5.0 (#5962) 2025-09-30 17:20:31 +00:00
David Perez
24a54ce214 Update hilt to v2.57.2 (#5961) 2025-09-30 17:20:15 +00:00
David Perez
8d76ef50d3 Firebase BOM update (#5960) 2025-09-30 17:19:59 +00:00
David Perez
22114d588a Update AndroidX libraries (#5959) 2025-09-29 21:39:52 +00:00
Patrick Honkonen
81245cf3e5 [PM-26111] Implement Review Export Screen and Navigation (#5946) 2025-09-29 21:12:09 +00:00
aj-rosado
fec6479f6a [PM-25452] Dont show move to organization when user has no orgs (#5862) 2025-09-29 20:01:32 +00:00
David Perez
a02a84ee08 PM-25642: Force sync or clear last sync time on sync notification (#5958) 2025-09-29 19:45:56 +00:00
Tyler
df63bb4b6c BRE-1158 Dockerfiles shared ownership (#5902) 2025-09-29 19:23:11 +00:00
David Perez
2a134c619d Update the Compose BOM (#5957) 2025-09-29 19:21:36 +00:00
Patrick Honkonen
5c5bd25d16 [PM-26094] Update Credential Manager library and remove stubs (#5947) 2025-09-29 18:41:35 +00:00
David Perez
2363b0d619 PM-26303: Remoe the 'Exit' button from the VaultScreen overflow menu (#5956) 2025-09-29 16:35:25 +00:00
David Perez
f0946e05d5 Fully extract more sync logic into the VaultSyncManager (#5912) 2025-09-29 16:35:00 +00:00
renovate[bot]
24ccebd822 [deps]: Lock file maintenance (#5954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 13:16:29 +00:00
David Perez
fd555e92d3 Commonize minor UI utility functions (#5945) 2025-09-26 20:34:25 +00:00
David Perez
eab2c17614 PM-26187: Add autofill help call-to-action (#5942) 2025-09-26 19:42:51 +00:00
David Perez
617be1fd95 PM-26181: Minor clean up and adjustments for browser autofill integration (#5941) 2025-09-26 15:10:31 +00:00
David Perez
d5d4caea62 PM-23292: Migrate toasts to snackbars (#5940) 2025-09-26 15:09:35 +00:00
Patrick Honkonen
7bf4acbb28 [PM-26110] Add verify password screen for item export (#5935) 2025-09-26 14:57:59 +00:00
André Bispo
2694138aa1 [PM-20977] Handle new sdk exception type. (#5937) 2025-09-26 14:47:21 +00:00
David Perez
d2645863ea PM-26161: Add badging for browser autofill (#5939) 2025-09-25 18:01:14 +00:00
Patrick Honkonen
3edd5bd852 [PM-26095] Add account selection screen for Credential Exchange (#5932) 2025-09-24 19:53:40 +00:00
David Perez
4cd5a1ed56 PM-26025: Add browser autofill screen for onboarding flow (#5931) 2025-09-24 19:50:13 +00:00
David Perez
c122f83fa6 Update onboarding secondary buttons to match designs (#5936) 2025-09-24 19:10:07 +00:00
bw-ghapp[bot]
b558d70703 Update SDK to 1.0.0-3175-c9758478 (#5922)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-24 17:52:04 +00:00
David Perez
89ad7818f9 Minor design tweaks for action cards (#5934) 2025-09-24 17:17:46 +00:00
David Perez
e91ba77105 PM-26151: Disable continue button for Autofill onboarding flow when autofill is disabled (#5933) 2025-09-24 16:55:50 +00:00
Patrick Honkonen
cc685b2307 [PM-26112] Handle Credential Exchange export requests (#5928) 2025-09-23 21:41:58 +00:00
David Perez
d14fba0c01 Remove unnecessary quotes (#5929) 2025-09-23 20:27:38 +00:00
Patrick Honkonen
e965134697 Update Credential Provider Events APIs (#5926) 2025-09-23 18:58:28 +00:00
David Perez
df34db52e4 PM-26106: Update quotes accross all strings (#5924) 2025-09-23 18:20:57 +00:00
David Perez
cf5d208516 Display the CipherKeyEncryption flag in debug menu (#5923) 2025-09-23 16:04:36 +00:00
André Bispo
d74040e7b9 [PM-25933] Replace SDK call updatePassword (#5916) 2025-09-23 15:11:07 +00:00
Patrick Honkonen
8a2bcfade8 [PM-25825] Add ImportItems navigation (#5915)
Co-authored-by: David Perez <david@livefront.com>
2025-09-22 21:33:08 +00:00
bw-ghapp[bot]
bc1dd730ec Update SDK to 1.0.0-3165-92bb5c30 (#5920)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-09-22 20:32:27 +00:00
David Perez
fa5053b5cc Add empty state for debug menu without feature flags (#5918) 2025-09-22 20:30:25 +00:00
bw-ghapp[bot]
ad46d8d7c0 Update SDK to 1.0.0-3157-1ca5a589 (#5917)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-09-22 15:43:16 +00:00
David Perez
98530ed33d PM-26027: Remove the UserManagedPrivilegedApps feature flag (#5914) 2025-09-19 20:09:54 +00:00
David Perez
e57af949fc PM-26026: save layout state through config change (#5913) 2025-09-19 19:03:40 +00:00
bw-ghapp[bot]
6f6aacabfb Update SDK to 1.0.0-3101-0eba924a (#5893)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-19 16:21:39 +00:00
David Perez
b0e0b44671 Pm 25258 browser autofill dialog (#5907) 2025-09-19 16:20:16 +00:00
David Perez
d53f3f313c Refactor Folder logic into FolderManager (#5904) 2025-09-19 15:37:31 +00:00
David Perez
4f244c52fa PM-25908: Process 400 responses from verification code APIs (#5900) 2025-09-19 15:29:28 +00:00
Patrick Honkonen
b4a31764c4 [PM-25824] Add "Import items" screen (#5906) 2025-09-19 13:59:26 +00:00
bw-ghapp[bot]
f4569cef2b Crowdin Pull (#5908)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-09-19 13:54:27 +00:00
Patrick Honkonen
b4926b72d9 Update registerExport to return RegisterExportResponse (#5903) 2025-09-18 14:37:14 +00:00
Patrick Honkonen
0f899df83c [PM-25826] Update folderRelationships type for cipher import (#5885) 2025-09-17 21:49:16 +00:00
Patrick Honkonen
ff03f49f43 [PM-25912] Remove ImportCredentialsRequest (#5901) 2025-09-17 20:42:42 +00:00
David Perez
2756bd9fde Refactor cipher logic into CipherManager (#5898) 2025-09-17 19:51:44 +00:00
Patrick Honkonen
a39f83349f Move NativeLibraryManager to data module (#5899) 2025-09-17 19:21:37 +00:00
Patrick Honkonen
7d3ed2af88 [PM-25822] Add ImportItemsViewModel and related strings (#5882) 2025-09-17 17:58:22 +00:00
David Perez
8de465381e Refactor Send logic into SendManager (#5892) 2025-09-17 14:37:14 +00:00
Patrick Honkonen
f22f4399be [PM-25664] Add CredentialExchangeImportManager for CXF payload import (#5872) 2025-09-16 21:30:24 +00:00
David Perez
766e6b1bb9 Update resources to use LocalResources (#5894) 2025-09-16 21:01:45 +00:00
David Perez
0fb364128e Update Androidx libraries to latest versions (#5890) 2025-09-16 21:01:28 +00:00
bw-ghapp[bot]
0cbce39499 Update SDK to 1.0.0-3005-5a722fd2 (#5860)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-16 17:13:26 +00:00
Patrick Honkonen
f954b0b941 Refactor Vault Sync Logic into VaultSyncManager (#5871) 2025-09-16 16:44:52 +00:00
David Perez
cfd0a5b8a5 Update the Protobuf library (#5891) 2025-09-16 16:40:12 +00:00
renovate[bot]
d61e1cb6f1 [deps]: Update actions/setup-java action to v5 (#5880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:48:08 +00:00
renovate[bot]
b31983da8b [deps]: Update actions/checkout action to v5 (#5879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:47:36 +00:00
David Perez
e22d309423 Update navigation libs to latest version (#5889) 2025-09-15 23:32:36 +00:00
Patrick Honkonen
9b53095b5e [PM-15051] Add CredentialExchangeRegistry (#5869) 2025-09-15 21:48:56 +00:00
David Perez
c6814c8870 Update to Kotlin v2.2.20 (#5888) 2025-09-15 21:12:11 +00:00
David Perez
7710ad8a73 Update to AGP v8.13.0 (#5887) 2025-09-15 19:55:17 +00:00
Patrick Honkonen
80b3a7e675 [PM-25663] Introduce CredentialExchangeImporter (#5868) 2025-09-15 19:44:03 +00:00
David Perez
8235045dad PM-24234: Add missing plurals (#5886) 2025-09-15 19:02:34 +00:00
Patrick Honkonen
481a8c8fbc [PM-25662] Add CredentialExchangeCompletionManager (#5867) 2025-09-15 18:36:48 +00:00
renovate[bot]
1dc6ea2227 [deps]: Lock file maintenance (#5881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 16:17:39 +00:00
renovate[bot]
6554234898 [deps]: Update gh minor (#5877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 16:16:55 +00:00
David Perez
e990397b29 Update Robolectric to v4.16 (#5833) 2025-09-15 15:33:36 +00:00
bw-ghapp[bot]
417835ef3f Crowdin Pull (#5874)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-09-15 13:48:03 +00:00
aj-rosado
39a6dd1c4b [PM-22320] Default to SHA1 on 2fas importer if algorithm is missing (#5875) 2025-09-13 08:57:13 +00:00
Patrick Honkonen
4093e61b09 [PM-25665] Add BitwardenImportCredentialsRequest and helper (#5870) 2025-09-11 17:51:57 +00:00
Patrick Honkonen
c4adf3ad42 [PM-25661] Add placeholder ProviderEvents API for credential import/export (#5866) 2025-09-11 14:06:57 +00:00
André Bispo
417a1494e3 [PM-25640] Dialog flickers when switching accounts (#5865) 2025-09-11 13:41:34 +00:00
André Bispo
ef39ea6d5d [PM-25624] Hide decryption errors from autofill list view (#5855) 2025-09-11 13:41:21 +00:00
Patrick Honkonen
f6c20e08d1 [PM-25637] Add CXF module for Credential Exchange support (#5858) 2025-09-11 12:49:06 +00:00
Álison Fernandes
987e065dd7 Fix sdk-update Test by using Java 21 in setup-android action (#5861) 2025-09-10 18:31:37 +00:00
Patrick Honkonen
ba7ee04281 [PM-15056] Add exportVaultDataToCxf function to VaultRepository (#5847) 2025-09-10 14:40:05 +00:00
Konrad
808d57edc5 Update untranslatable strings (#5854) 2025-09-10 13:43:50 +00:00
David Perez
3356925c7a Update to Java 21 (#5835) 2025-09-10 13:41:41 +00:00
bw-ghapp[bot]
0487d95122 Update SDK to 1.0.0-2944-8447df0c (#5830)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-10 09:03:31 +00:00
bw-ghapp[bot]
0834a7a883 Crowdin Pull (#5853)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-09-08 16:17:54 +00:00
David Perez
2b0e8f9941 Update appVersionName to 2025.9.1 (#5848) 2025-09-05 21:52:04 +00:00
Patrick Honkonen
0702078b04 [PM-25523] Add importCxfPayload to VaultRepository (#5846) 2025-09-05 19:57:43 +00:00
David Perez
46c7e79039 Cleanup minor lint warnings in string resources (#5843) 2025-09-05 19:56:46 +00:00
David Perez
1d6e733c08 Update protobuff library to v4.32.0 (#5845) 2025-09-05 18:56:12 +00:00
Patrick Honkonen
a298b85374 [PM-25522] Add importCxf function to VaultSdkSource (#5841) 2025-09-05 18:56:01 +00:00
David Perez
fe79ea4822 PM-25162: Fix a navigation bug in bottom navigation (#5842) 2025-09-05 16:38:11 +00:00
Patrick Honkonen
4c50f873e2 [PM-15055] Add SDK support for exporting vault data to CXF (#5840) 2025-09-05 16:29:32 +00:00
David Perez
2bd4834b14 PM-25478: Update sends and folders while vault is locked (#5837) 2025-09-05 14:32:49 +00:00
David Perez
393931a5c6 PM-25474: Allow SYNC_CIPHER_DELETE notification to delete Cipher for inactive user (#5836) 2025-09-05 14:32:34 +00:00
bw-ghapp[bot]
fe6346013b Crowdin Pull (#5838)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-09-05 13:38:33 +00:00
Konrad
41e499fdf5 [PM-25133] Plural forms (#5773)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-09-04 18:13:58 +00:00
David Perez
aa39e6c6be PM-25462: Allow SYNC_FOLDER_DELETE notification to delete Folders for inactive user (#5832) 2025-09-04 17:31:00 +00:00
David Perez
eec4233486 PM-25431: Allow SYNC_SEND_DELETE notification to delete sends for inactive user (#5827) 2025-09-04 15:29:35 +00:00
David Perez
58db64da1a Update Kotlin to the latest version v2.2.10 (#5828) 2025-09-03 22:40:07 +00:00
David Perez
a7d0d6844d Update Hilt to v2.57.1 (#5826) 2025-09-03 20:09:57 +00:00
David Perez
249e1d3a5c Update Firebase BOM (#5823) 2025-09-03 18:00:03 +00:00
bw-ghapp[bot]
d8f3e7af92 Update SDK to 1.0.0-2887-7b5d9db2 (#5815)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-09-03 14:08:58 +00:00
Patrick Honkonen
1c4e4dcaf4 [PM-25394] Sort default user collections by Organization name (#5819) 2025-09-03 14:04:45 +00:00
David Perez
9adc25471e Update the Compose BOM and Androidx Lifecycle libraries (#5820) 2025-09-03 13:44:51 +00:00
Patrick Honkonen
ec6562336c [PM-23665] Refactor FIDO2 credential discovery (#5817) 2025-09-03 13:15:32 +00:00
Álison Fernandes
f402391ed8 [PM-25396] Publish store builds when release branches are updated (#5821) 2025-09-03 12:45:05 +00:00
David Perez
9b074f2106 PM-25393: Allow push notifications to update a cipher while vault is locked (#5818) 2025-09-02 20:27:30 +00:00
David Perez
3fa33faa35 Update AGP to v8.12.2 (#5816) 2025-09-02 18:29:25 +00:00
Patrick Honkonen
e1434dfe21 [PM-25327] Display default user collections first (#5810) 2025-09-02 18:25:55 +00:00
Álison Fernandes
659bbc5169 [PM-24930] Fix updating open SDK PRs and set token permissions (#5804) 2025-09-01 13:59:33 +00:00
bw-ghapp[bot]
dfa1f24c30 Crowdin Pull (#5807)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-08-29 16:30:03 +00:00
bw-ghapp[bot]
4f65c3f7d3 Update SDK to 1.0.0-2825-e05ba6eb (#5809)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-08-29 13:34:03 +00:00
Patrick Honkonen
0f74c3dded Fix plurals string for decryption error (#5796) 2025-08-28 15:48:05 +00:00
Patrick Honkonen
f7139b8b91 [PM-25239] Remove unnecessary vault sync from Fido2CredentialStoreImpl (#5794) 2025-08-28 15:47:44 +00:00
David Perez
2b35ac0d3a PM-25143: Retain intent data on recreate (#5787) 2025-08-27 19:45:16 +00:00
David Perez
4a79d7e6c8 PM-25238: Remove debug toast (#5792) 2025-08-27 16:43:03 +00:00
bw-ghapp[bot]
b9a496aa57 Update SDK to 1.0.0-2807-bc66e3d0 (#5785)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-08-27 15:23:01 +00:00
André Bispo
0a398839c4 [PM-18210] Cipher key encryption error handling (#5611)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-08-27 13:00:00 +00:00
Patrick Honkonen
aab8198457 [PM-25057] Refactor card restriction logic in AutofillCipherProvider (#5788) 2025-08-26 18:47:18 +00:00
David Perez
d2d89b5a0f PM-25193: Clear last sync time on push notification for inactive user (#5784) 2025-08-26 18:28:51 +00:00
David Perez
ddadd0135f PM-25194: Fix CollectionTypeJson data type in database (#5786) 2025-08-25 21:40:59 +00:00
David Perez
dc198eaf72 PM-25125: Refactor user state managment into UserStateManager (#5774) 2025-08-25 18:45:43 +00:00
David Perez
ff23dc3ab2 PM-25069: Update VaultAddEditViewModel toasts to snackbars (#5769) 2025-08-25 18:45:12 +00:00
Patrick Honkonen
191ff4c652 Update ARCHITECTURE.md (#5765) 2025-08-25 18:18:38 +00:00
bw-ghapp[bot]
99ab2245f6 Update SDK to 1.0.0-2681-1a956d45 (#5756)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-08-22 18:57:39 +00:00
808 changed files with 44863 additions and 23508 deletions

105
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,105 @@
# Claude Guidelines
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
## Core Directives
**You MUST follow these directives at all times.**
1. **Adhere to Architecture**: All code modifications MUST follow patterns in `docs/ARCHITECTURE.md`
2. **Follow Code Style**: ALWAYS follow `docs/STYLE_AND_BEST_PRACTICES.md`
3. **Error Handling**: Use Result types and sealed classes per architecture guidelines
4. **Best Practices**: Follow Kotlin idioms (immutability, appropriate data structures, coroutines)
5. **Document Everything**: All public APIs require KDoc documentation
6. **Dependency Management**: Use Hilt DI patterns as established in the project
7. **Use Established Patterns**: Leverage existing components before creating new ones
8. **File References**: Use file:line_number format when referencing code
## Code Quality Standards
### Module Organization
**Core Library Modules:**
- **`:core`** - Common utilities and managers shared across multiple modules
- **`:data`** - Data sources, database, data repositories
- **`:network`** - Networking interfaces, API clients, network utilities
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
**Application Modules:**
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
**Specialized Library Modules:**
- **`:authenticatorbridge`** - Communication bridge between :authenticator and :app
- **`:annotation`** - Custom annotations for code generation (Hilt, Room, etc.)
- **`:cxf`** - Android Credential Exchange (CXF/CXP) integration layer
### Patterns Enforcement
- **MVVM + UDF**: ViewModels with StateFlow, Compose UI
- **Hilt DI**: Interface injection, @HiltViewModel, @Inject constructor
- **Testing**: JUnit 5, MockK, Turbine for Flow testing
- **Error Handling**: Sealed Result types, no throws in business logic
## Security Requirements
**Every change must consider:**
- Zero-knowledge architecture preservation
- Proper encryption key handling (Android Keystore)
- Input validation and sanitization
- Secure data storage patterns
- Threat model implications
## Workflow Practices
### Before Implementation
1. Read relevant architecture documentation
2. Search for existing patterns to follow
3. Identify affected modules and dependencies
4. Consider security implications
### During Implementation
1. Follow existing code style in surrounding files
2. Write tests alongside implementation
3. Add KDoc to all public APIs
4. Validate against architecture guidelines
### After Implementation
1. Ensure all tests pass
2. Verify compilation succeeds
3. Review security considerations
4. Update relevant documentation
## Anti-Patterns
**Avoid these:**
- Creating new patterns when established ones exist
- Exception-based error handling in business logic
- Direct dependency access (use DI)
- Mutable state in ViewModels (use StateFlow)
- Missing null safety handling
- Undocumented public APIs
- Tight coupling between modules
## Communication & Decision-Making
Always clarify ambiguous requirements before implementing. Use specific questions:
- "Should this use [Approach A] or [Approach B]?"
- "This affects [X]. Proceed or review first?"
- "Expected behavior for [specific requirement]?"
Defer high-impact decisions to the user:
- Architecture/module changes, public API modifications
- Security mechanisms, database migrations
- Third-party library additions
## Reference Documentation
Critical resources:
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
**Do not duplicate information from these files - reference them instead.**

View File

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

View File

@@ -0,0 +1,110 @@
---
name: reviewing-changes
description: Performs comprehensive code reviews for Bitwarden Android projects, verifying architecture compliance, style guidelines, compilation safety, test coverage, and security requirements. Use when reviewing pull requests, checking commits, analyzing code changes, verifying Bitwarden coding standards, evaluating MVVM patterns, checking Hilt DI usage, reviewing security implementations, or assessing test coverage. Automatically invoked by CI pipeline or manually for interactive code reviews.
---
# Reviewing Changes
## Instructions
Follow this process to review code changes for Bitwarden Android:
### Step 1: Understand Context
Start with high-level assessment of the change's purpose and approach. Read PR/commit descriptions and understand what problem is being solved.
### Step 2: Verify Compliance
Systematically check each area against Bitwarden standards documented in `CLAUDE.md`:
1. **Architecture**: Follow patterns in `docs/ARCHITECTURE.md`
- MVVM + UDF (ViewModels with `StateFlow`, Compose UI)
- Hilt DI (interface injection, `@HiltViewModel`)
- Repository pattern and proper data flow
2. **Style**: Adhere to `docs/STYLE_AND_BEST_PRACTICES.md`
- Naming conventions, code organization, formatting
- Kotlin idioms (immutability, null safety, coroutines)
3. **Compilation**: Analyze for potential build issues
- Import statements and dependencies
- Type safety and null safety
- API compatibility and deprecation warnings
- Resource references and manifest requirements
4. **Testing**: Verify appropriate test coverage
- Unit tests for business logic and utility functions
- Integration tests for complex workflows
- UI tests for user-facing features when applicable
- Test coverage for edge cases and error scenarios
5. **Security**: Given Bitwarden's security-focused nature
- Proper handling of sensitive data
- Secure storage practices (Android Keystore)
- Authentication and authorization patterns
- Data encryption and decryption flows
- Zero-knowledge architecture preservation
### Step 3: Document Findings
Identify specific violations with `file:line_number` references. Be precise about locations.
### Step 4: Provide Recommendations
Give actionable recommendations for improvements. Explain why changes are needed and suggest specific solutions.
### Step 5: Flag Critical Issues
Highlight issues that must be addressed before merge. Distinguish between blockers and suggestions.
### Step 6: Acknowledge Quality
Note well-implemented patterns (briefly, without elaboration). Keep positive feedback concise.
## Review Anti-Patterns (DO NOT)
- Be nitpicky about linter-catchable style issues
- Review without understanding context - ask for clarification first
- Focus only on new code - check surrounding context for issues
- Request changes outside the scope of this changeset
## Examples
### Good Review Format
```markdown
## Summary
This PR adds biometric authentication to the login flow, implementing MVVM pattern with proper state management.
## Critical Issues
- `app/login/LoginViewModel.kt:45` - Mutable state exposed; use `StateFlow` instead of `MutableStateFlow`
- `data/auth/BiometricRepository.kt:120` - Missing null safety check on `biometricPrompt` result
## Suggested Improvements
- Consider extracting biometric prompt logic to separate use case class
- Add integration tests for biometric failure scenarios
- `app/login/LoginScreen.kt:89` - Consider using existing `BitwardenButton` component
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
## Action Items
1. Fix mutable state exposure in `LoginViewModel`
2. Add null safety check in `BiometricRepository`
3. Consider adding integration tests for error flows
```
### What to Focus On
**DO focus on:**
- Architecture violations (incorrect patterns)
- Security issues (data handling, encryption)
- Missing tests for critical paths
- Compilation risks (type safety, null safety)
**DON'T focus on:**
- Minor formatting (handled by linters)
- Personal preferences without architectural basis
- Issues outside the changeset scope

11
.github/CODEOWNERS vendored
View File

@@ -10,6 +10,11 @@
# Actions and workflow changes.
.github/ @bitwarden/dept-development-mobile
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme
# Auth
# app/src/main/java/com/x8bit/bitwarden/data/auth @bitwarden/team-auth-dev
# app/src/main/java/com/x8bit/bitwarden/ui/auth @bitwarden/team-auth-dev
@@ -48,3 +53,9 @@
# app/src/main/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
# app/src/test/java/com/x8bit/bitwarden/data/vault @bitwarden/team-vault-dev
# app/src/test/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
# Docker-related files
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre

View File

@@ -4,12 +4,12 @@ inputs:
java-version:
description: 'Java version to use'
required: false
default: '17'
default: '21'
runs:
using: 'composite'
steps:
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -31,12 +31,12 @@ runs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ inputs.java-version }}

View File

@@ -27,6 +27,9 @@
],
"matchManagers": [
"gradle"
],
"excludePackageNames": [
"com.github.bumptech.glide:compose"
]
},
{

159
.github/workflows/_version.yml vendored Normal file
View File

@@ -0,0 +1,159 @@
name: Calculate Version Name and Number
on:
workflow_dispatch:
inputs:
app_codename:
description: "App Name - e.g. 'bwpm' or 'bwa'"
base_version_number:
description: "Base Version Number - Will be added to the calculated version number"
type: number
default: 0
version_name:
description: "Version Name Override - e.g. '2024.8.1'"
version_number:
description: "Version Number Override - e.g. '1021'"
patch_version:
description: "Patch Version Override - e.g. '999'"
distinct_id:
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
skip_checkout:
description: "Skip checking out the repository"
type: boolean
workflow_call:
inputs:
app_codename:
description: "App Name - e.g. 'bwpm' or 'bwa'"
type: string
base_version_number:
description: "Base Version Number - Will be added to the calculated version number"
type: number
default: 0
version_name:
description: "Version Name Override - e.g. '2024.8.1'"
type: string
version_number:
description: "Version Number Override - e.g. '1021'"
type: string
patch_version:
description: "Patch Version Override - e.g. '999'"
type: string
distinct_id:
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
type: string
skip_checkout:
description: "Skip checking out the repository"
type: boolean
outputs:
version_name:
description: "Version Name"
value: ${{ jobs.calculate-version.outputs.version_name }}
version_number:
description: "Version Number"
value: ${{ jobs.calculate-version.outputs.version_number }}
env:
APP_CODENAME: ${{ inputs.app_codename }}
BASE_VERSION_NUMBER: ${{ inputs.base_version_number || 0 }}
jobs:
calculate-version:
name: Calculate Version Name and Number
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
version_name: ${{ steps.calc-version-name.outputs.version_name }}
version_number: ${{ steps.calc-version-number.outputs.version_number }}
steps:
- name: Log inputs to job summary
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Echo distinct ID ${{ github.event.inputs.distinct_id }}
run: echo ${{ github.event.inputs.distinct_id }}
- name: Check out repository
if: ${{ !inputs.skip_checkout || false }}
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
- name: Calculate version name
id: calc-version-name
run: |
output() {
local version_name=$1
echo "version_name=$version_name" >> $GITHUB_OUTPUT
}
# override version name if provided
if [[ ! -z "${{ inputs.version_name }}" ]]; then
version_name=${{ inputs.version_name }}
echo "::warning::Override applied: $version_name"
output "$version_name"
exit 0
fi
current_year=$(date +%Y)
current_month=$(date +%-m)
latest_tag_version=$(git tag -l --sort=-creatordate | grep "$APP_CODENAME" | head -n 1)
if [[ -z "$latest_tag_version" ]]; then
version_name="${current_year}.${current_month}.${{ inputs.patch_version || 0 }}"
echo "::warning::No tags found, did you checkout? Calculating version from current date: $version_name"
output "$version_name"
exit 0
fi
# Git tag was found, calculate version from latest tag
latest_version=${latest_tag_version:1} # remove 'v' from tag version
latest_major_version=$(echo $latest_version | cut -d "." -f 1)
latest_minor_version=$(echo $latest_version | cut -d "." -f 2)
patch_version=0
if [[ ! -z "${{ inputs.patch_version }}" ]]; then
patch_version=${{ inputs.patch_version }}
echo "::warning::Patch Version Override applied: $patch_version"
elif [[ "$current_year" == "$latest_major_version" && "$current_month" == "$latest_minor_version" ]]; then
latest_patch_version=$(echo $latest_version | cut -d "." -f 3)
patch_version=$(($latest_patch_version + 1))
fi
version_name="${current_year}.${current_month}.${patch_version}"
output "$version_name"
- name: Calculate version number
id: calc-version-number
run: |
# override version number if provided
if [[ ! -z "${{ inputs.version_number }}" ]]; then
version_number=${{ inputs.version_number }}
echo "::warning::Override applied: $version_number"
echo "version_number=$version_number" >> $GITHUB_OUTPUT
exit 0
fi
version_number=$(($GITHUB_RUN_NUMBER + ${{ env.BASE_VERSION_NUMBER }}))
echo "version_number=$version_number" >> $GITHUB_OUTPUT
- name: Create version info JSON
run: |
json='{
"version_number": "${{ steps.calc-version-number.outputs.version_number }}",
"version_name": "${{ steps.calc-version-name.outputs.version_name }}"
}'
echo "$json" > version_info.json
echo "## version-info.json" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo "$json" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Upload version info artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: version-info
path: version_info.json

View File

@@ -15,6 +15,9 @@ on:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
@@ -28,7 +31,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 17
JAVA_VERSION: 21
permissions:
contents: read
@@ -36,25 +39,42 @@ permissions:
id-token: write
jobs:
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwa"
base_version_number: 0
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
secrets: inherit
build:
name: Build Authenticator
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -76,13 +96,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -101,6 +121,7 @@ jobs:
publish_playstore:
name: Publish Authenticator Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
strategy:
@@ -110,10 +131,12 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -145,27 +168,27 @@ jobs:
mkdir -p ${{ github.workspace }}/secrets
mkdir -p ${{ github.workspace }}/keystores
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name authenticator_apk-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_apk-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name authenticator_aab-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_aab-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name com.bitwarden.authenticator-google-services.json --file ${{ github.workspace }}/authenticator/src/google-services.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--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: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
- name: Download Play Store credentials
@@ -176,7 +199,7 @@ jobs:
run: |
mkdir -p ${{ github.workspace }}/secrets
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
- name: AZ Logout
@@ -186,10 +209,10 @@ jobs:
if: ${{ inputs.publish-to-play-store }}
run: |
bundle exec fastlane run validate_play_store_json_key \
json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }}
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -211,7 +234,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -219,44 +242,54 @@ jobs:
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
$GITHUB_REPOSITORY \
$GITHUB_REF_NAME \
$GITHUB_SHA \
$GITHUB_RUN_ID \
$GITHUB_RUN_ATTEMPT
"$GITHUB_REPOSITORY" \
"$GITHUB_REF_NAME" \
"$GITHUB_SHA" \
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
- name: Increment version
env:
DEFAULT_VERSION_CODE: ${{ github.run_number }}
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
DEFAULT_VERSION_CODE=$GITHUB_RUN_NUMBER
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME_INPUT"
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY
echo "Version Number: $VERSION_CODE" >> $GITHUB_STEP_SUMMARY
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'aab' }}
env:
STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-STORE-PASSWORD }}
KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-KEY-PASSWORD }}
run: |
bundle exec fastlane bundleAuthenticatorRelease \
storeFile:${{ github.workspace }}/keystores/authenticator_aab-keystore.jks \
storePassword:'${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-STORE-PASSWORD }}' \
keyAlias:authenticatorupload \
keyPassword:'${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-KEY-PASSWORD }}'
storeFile:"${{ github.workspace }}/keystores/authenticator_aab-keystore.jks" \
storePassword:"$STORE_PASSWORD" \
keyAlias:"authenticatorupload" \
keyPassword:"$KEY_PASSWORD"
- name: Generate release Play Store APK
if: ${{ matrix.variant == 'apk' }}
env:
STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-STORE-PASSWORD }}
KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-KEY-PASSWORD }}
run: |
bundle exec fastlane buildAuthenticatorRelease \
storeFile:${{ github.workspace }}/keystores/authenticator_apk-keystore.jks \
storePassword:'${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-STORE-PASSWORD }}' \
keyAlias:bitwardenauthenticator \
keyPassword:'${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-KEY-PASSWORD }}'
storeFile:"${{ github.workspace }}/keystores/authenticator_apk-keystore.jks" \
storePassword:"$STORE_PASSWORD" \
keyAlias:"bitwardenauthenticator" \
keyPassword:"$KEY_PASSWORD"
- name: Upload release Play Store .aab artifact
if: ${{ matrix.variant == 'aab' }}
@@ -312,7 +345,7 @@ jobs:
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
run: |
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
serviceCredentialsFile:${{ env.FIREBASE_CREDS_PATH }}
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
# bundles
@@ -322,4 +355,4 @@ jobs:
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
run: |
bundle exec fastlane publishAuthenticatorReleaseToGooglePlayStore \
serviceCredentialsFile:${{ env.PLAY_STORE_CREDS_FILE }} \
serviceCredentialsFile:"$PLAY_STORE_CREDS_FILE" \

View File

@@ -15,20 +15,23 @@ on:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
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: 17
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
permissions:
@@ -37,25 +40,43 @@ permissions:
id-token: write
jobs:
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwpm"
# Start from 11000 to prevent collisions with mobile build version codes
base_version_number: 11000
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
secrets: inherit
build:
name: Build
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -77,13 +98,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -109,6 +130,7 @@ jobs:
publish_playstore:
name: Publish Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
strategy:
@@ -118,10 +140,12 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -154,19 +178,19 @@ jobs:
mkdir -p ${{ github.workspace }}/app/src/standardBeta
mkdir -p ${{ github.workspace }}/app/src/standardRelease
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_play-keystore.jks --file ${{ github.workspace }}/keystores/app_play-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_upload-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_play-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_play-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_upload-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name google-services.json --file ${{ github.workspace }}/app/src/standardRelease/google-services.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
- name: Download Firebase credentials
@@ -177,14 +201,14 @@ jobs:
run: |
mkdir -p ${{ github.workspace }}/secrets
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -206,7 +230,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -214,64 +238,67 @@ jobs:
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
$GITHUB_REPOSITORY \
$GITHUB_REF_NAME \
$GITHUB_SHA \
$GITHUB_RUN_ID \
$GITHUB_RUN_ATTEMPT
"$GITHUB_REPOSITORY" \
"$GITHUB_REF_NAME" \
"$GITHUB_SHA" \
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
DEFAULT_VERSION_CODE=$((11000+GITHUB_RUN_NUMBER))
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }} \
versionName:${{ inputs.version-name }}
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
env:
UPLOAD-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
UPLOAD_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreRelease \
storeFile:app_upload-keystore.jks \
storePassword:${{ env.UPLOAD-KEYSTORE-PASSWORD }} \
storePassword:$UPLOAD_KEYSTORE_PASSWORD \
keyAlias:upload \
keyPassword:${{ env.UPLOAD-KEYSTORE-PASSWORD }}
keyPassword:$UPLOAD_KEYSTORE_PASSWORD
- name: Generate beta Play Store bundle
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
env:
UPLOAD-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
UPLOAD-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
UPLOAD_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreBeta \
storeFile:app_beta_upload-keystore.jks \
storePassword:${{ env.UPLOAD-BETA-KEYSTORE-PASSWORD }} \
storePassword:$UPLOAD_BETA_KEYSTORE_PASSWORD \
keyAlias:bitwarden-beta-upload \
keyPassword:${{ env.UPLOAD-BETA-KEY-PASSWORD }}
keyPassword:$UPLOAD_BETA_KEY_PASSWORD
- name: Generate release Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreReleaseApk \
storeFile:app_play-keystore.jks \
storePassword:${{ env.PLAY-KEYSTORE-PASSWORD }} \
storePassword:$PLAY_KEYSTORE_PASSWORD \
keyAlias:bitwarden \
keyPassword:${{ env.PLAY-KEYSTORE-PASSWORD }}
keyPassword:$PLAY_KEYSTORE_PASSWORD
- name: Generate beta Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
PLAY_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreBetaApk \
storeFile:app_beta_play-keystore.jks \
storePassword:${{ env.PLAY-BETA-KEYSTORE-PASSWORD }} \
storePassword:$PLAY_BETA_KEYSTORE_PASSWORD \
keyAlias:bitwarden-beta \
keyPassword:${{ env.PLAY-BETA-KEY-PASSWORD }}
keyPassword:$PLAY_BETA_KEY_PASSWORD
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
@@ -399,8 +426,8 @@ jobs:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeReleasePlayStoreToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
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') }}
@@ -408,8 +435,8 @@ jobs:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeBetaPlayStoreToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Verify Play Store credentials
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
@@ -417,7 +444,7 @@ jobs:
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.ref_name == 'main') }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
@@ -425,14 +452,17 @@ jobs:
publish_fdroid:
name: Publish F-Droid artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -461,9 +491,9 @@ jobs:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_fdroid-keystore.jks --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
- name: Download Firebase credentials
@@ -474,14 +504,14 @@ jobs:
run: |
mkdir -p ${{ github.workspace }}/secrets
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -503,7 +533,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -511,47 +541,48 @@ jobs:
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
$GITHUB_REPOSITORY \
$GITHUB_REF_NAME \
$GITHUB_SHA \
$GITHUB_RUN_ID \
$GITHUB_RUN_ATTEMPT
"$GITHUB_REPOSITORY" \
"$GITHUB_REF_NAME" \
"$GITHUB_SHA" \
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
# Start from 11000 to prevent collisions with mobile build version codes
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
DEFAULT_VERSION_CODE=$((11000+GITHUB_RUN_NUMBER))
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
versionName:$VERSION_NAME
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY
echo "Version Number: $VERSION_CODE" >> $GITHUB_STEP_SUMMARY
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
- name: Generate F-Droid artifacts
env:
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane assembleFDroidReleaseApk \
storeFile:app_fdroid-keystore.jks \
storePassword:"${{ env.FDROID_STORE_PASSWORD }}" \
storePassword:$FDROID_STORE_PASSWORD \
keyAlias:bitwarden \
keyPassword:"${{ env.FDROID_STORE_PASSWORD }}"
keyPassword:$FDROID_STORE_PASSWORD
- name: Generate F-Droid Beta Artifacts
env:
FDROID-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEYSTORE-PASSWORD }}
FDROID-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEY-PASSWORD }}
FDROID_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEYSTORE-PASSWORD }}
FDROID_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane assembleFDroidBetaApk \
storeFile:app_beta_fdroid-keystore.jks \
storePassword:"${{ env.FDROID-BETA-KEYSTORE-PASSWORD }}" \
storePassword:$FDROID_BETA_KEYSTORE_PASSWORD \
keyAlias:bitwarden-beta \
keyPassword:"${{ env.FDROID-BETA-KEY-PASSWORD }}"
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -601,5 +632,5 @@ jobs:
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
run: |
bundle exec fastlane distributeReleaseFDroidToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_FDROID_FIREBASE_CREDS_PATH }}
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_FDROID_FIREBASE_CREDS_PATH

View File

@@ -3,7 +3,7 @@ name: Cron / Sync Google Privileged Browsers List
on:
schedule:
# Run weekly on Monday at 00:00 UTC
- cron: '0 0 * * 1'
- cron: "0 0 * * 1"
workflow_dispatch:
env:
@@ -21,25 +21,26 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: true
- name: Download Google Privileged Browsers List
run: curl -s $SOURCE_URL -o $GOOGLE_FILE
run: curl -s "$SOURCE_URL" -o "$GOOGLE_FILE"
- name: Check for changes
id: check-changes
run: |
if git diff --quiet -- $GOOGLE_FILE; then
if git diff --quiet -- "$GOOGLE_FILE"; then
echo "👀 No changes detected, skipping..."
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "👀 Changes detected, validating fido2_privileged_google.json..."
python .github/scripts/validate-json/validate_json.py validate $GOOGLE_FILE
if [ $? -ne 0 ]; then
if ! python .github/scripts/validate-json/validate_json.py validate "$GOOGLE_FILE"; then
echo "::error::JSON validation failed for $GOOGLE_FILE"
exit 1
fi
@@ -47,14 +48,14 @@ jobs:
echo "👀 fido2_privileged_google.json is valid, checking for duplicates..."
# Check for duplicates between Google and Community files
python .github/scripts/validate-json/validate_json.py duplicates $GOOGLE_FILE $COMMUNITY_FILE duplicates.txt
python .github/scripts/validate-json/validate_json.py duplicates "$GOOGLE_FILE" "$COMMUNITY_FILE" duplicates.txt
if [ -f duplicates.txt ]; then
echo "::warning::Duplicate package names found between Google and Community files."
echo "duplicates_found=true" >> $GITHUB_OUTPUT
echo "duplicates_found=true" >> "$GITHUB_OUTPUT"
else
echo "✅ No duplicate package names found between Google and Community files"
echo "duplicates_found=false" >> $GITHUB_OUTPUT
echo "duplicates_found=false" >> "$GITHUB_OUTPUT"
fi
- name: Create branch and commit
@@ -65,11 +66,11 @@ jobs:
BRANCH_NAME="cron-sync-privileged-browsers/$GITHUB_RUN_NUMBER-sync"
git config user.name "GitHub Actions Bot"
git config user.email "actions@github.com"
git checkout -b $BRANCH_NAME
git add $GOOGLE_FILE
git checkout -b "$BRANCH_NAME"
git add "$GOOGLE_FILE"
git commit -m "Update Google privileged browsers list"
git push origin $BRANCH_NAME
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
git push origin "$BRANCH_NAME"
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
echo "🌱 Branch created: $BRANCH_NAME"
- name: Create Pull Request
@@ -89,10 +90,10 @@ jobs:
fi
# Use echo -e to interpret escape sequences and pipe to gh pr create
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
echo -e "$PR_BODY" | gh pr create \
--title "Update Google privileged browsers list" \
--body-file - \
--base main \
--head $BRANCH_NAME \
--head "$BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci")
--label "t:ci"

View File

@@ -4,7 +4,7 @@ run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 5'
- cron: "0 0 * * 5"
jobs:
crowdin-sync:
@@ -16,7 +16,9 @@ jobs:
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -50,7 +52,7 @@ jobs:
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Download translations
uses: crowdin/github-action@590c05e09a29f392b203faf4d6aa8e0cd32c7835 # v2.9.1
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -16,7 +16,9 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -33,7 +35,7 @@ jobs:
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@590c05e09a29f392b203faf4d6aa8e0cd32c7835 # v2.9.1
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -4,16 +4,16 @@ on:
workflow_dispatch:
inputs:
artifact-run-id:
description: 'GitHub Action Run ID containing artifacts'
description: "GitHub Action Run ID containing artifacts"
required: true
type: string
release-ticket-id:
description: 'Release Ticket ID - e.g. RELEASE-1762'
description: "Release Ticket ID - e.g. RELEASE-1762"
required: true
type: string
env:
ARTIFACTS_PATH: artifacts
ARTIFACTS_PATH: artifacts
jobs:
create-release:
@@ -25,9 +25,10 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: true
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
@@ -40,7 +41,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
run: |
workflow_data=$(gh run view $ARTIFACT_RUN_ID --json headBranch,workflowName)
workflow_data=$(gh run view "$ARTIFACT_RUN_ID" --json headBranch,workflowName)
release_branch=$(echo "$workflow_data" | jq -r .headBranch)
workflow_name=$(echo "$workflow_data" | jq -r .workflowName)
@@ -52,8 +53,8 @@ jobs:
echo "🔖 Release branch: $release_branch"
echo "🔖 Workflow name: $workflow_name"
echo "release_branch=$release_branch" >> $GITHUB_OUTPUT
echo "workflow_name=$workflow_name" >> $GITHUB_OUTPUT
echo "release_branch=$release_branch" >> "$GITHUB_OUTPUT"
echo "workflow_name=$workflow_name" >> "$GITHUB_OUTPUT"
case "$workflow_name" in
*"Password Manager"* | "Build")
@@ -71,8 +72,8 @@ jobs:
esac
echo "🔖 App name: $app_name"
echo "🔖 App name suffix: $app_name_suffix"
echo "app_name=$app_name" >> $GITHUB_OUTPUT
echo "app_name_suffix=$app_name_suffix" >> $GITHUB_OUTPUT
echo "app_name=$app_name" >> "$GITHUB_OUTPUT"
echo "app_name_suffix=$app_name_suffix" >> "$GITHUB_OUTPUT"
- name: Get version info from run logs and set release tag name
id: get_release_info
@@ -81,7 +82,7 @@ jobs:
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_APP_NAME_SUFFIX: ${{ steps.get_release_branch.outputs.app_name_suffix }}
run: |
workflow_log=$(gh run view $ARTIFACT_RUN_ID --log)
workflow_log=$(gh run view "$ARTIFACT_RUN_ID" --log)
version_number_with_trailing_dot=$(grep -m 1 "Setting version code to" <<< "$workflow_log" | sed 's/.*Setting version code to //')
version_number=${version_number_with_trailing_dot%.} # remove trailing dot
@@ -103,28 +104,28 @@ jobs:
echo "✅ Found version number: $version_number"
fi
echo "version_number=$version_number" >> $GITHUB_OUTPUT
echo "version_name=$version_name" >> $GITHUB_OUTPUT
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
tag_name="v$version_name-$_APP_NAME_SUFFIX" # e.g. v2025.6.0-bwpm
echo "🔖 New tag name: $tag_name"
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
echo "🔖 Last release tag: $last_release_tag"
echo "last_release_tag=$last_release_tag" >> $GITHUB_OUTPUT
echo "last_release_tag=$last_release_tag" >> "$GITHUB_OUTPUT"
- name: Download artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
run: |
gh run download $ARTIFACT_RUN_ID -D $ARTIFACTS_PATH
file_count=$(find $ARTIFACTS_PATH -type f | wc -l)
gh run download "$ARTIFACT_RUN_ID" -D "$ARTIFACTS_PATH"
file_count=$(find "$ARTIFACTS_PATH" -type f | wc -l)
echo "Downloaded $file_count file(s)."
if [ "$file_count" -gt 0 ]; then
echo "Downloaded files:"
find $ARTIFACTS_PATH -type f
find "$ARTIFACTS_PATH" -type f
fi
# Files that won't be included in any release
@@ -148,12 +149,12 @@ jobs:
)
for file in "${files_to_remove[@]}"; do
find $ARTIFACTS_PATH -name "$file" -type f -delete
find "$ARTIFACTS_PATH" -name "$file" -type f -delete
done
echo "🔖 Removed internal artifacts."
echo ""
echo "🔖 Files to be included in the release:"
find $ARTIFACTS_PATH -type f
find "$ARTIFACTS_PATH" -type f
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -183,7 +184,7 @@ jobs:
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
run: |
echo "Getting product release notes"
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py $_RELEASE_TICKET_ID $_JIRA_API_EMAIL $_JIRA_API_TOKEN)
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN")
if [[ -z "$product_release_notes" || $product_release_notes == "Error checking"* ]]; then
echo "::warning::Failed to fetch release notes from Jira. Output: $product_release_notes"
@@ -219,12 +220,12 @@ jobs:
--notes-start-tag "$_LAST_RELEASE_TAG" \
--latest=$is_latest_release \
--draft \
$ARTIFACTS_PATH/*/*)
"$ARTIFACTS_PATH/*/*")
# Extract release tag from URL
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
echo "release_id_from_url=$release_id_from_url" >> $GITHUB_OUTPUT
echo "url=$release_url" >> $GITHUB_OUTPUT
echo "release_id_from_url=$release_id_from_url" >> "$GITHUB_OUTPUT"
echo "url=$release_url" >> "$GITHUB_OUTPUT"
echo "✅ Release created: $release_url"
echo "🔖 Release ID from URL: $release_id_from_url"
@@ -252,7 +253,7 @@ jobs:
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
# draft release links change after editing
echo "release_url=$new_release_url" >> $GITHUB_OUTPUT
echo "release_url=$new_release_url" >> "$GITHUB_OUTPUT"
- name: Add Release Summary
env:
@@ -263,20 +264,26 @@ jobs:
_RELEASE_BRANCH: ${{ steps.get_release_branch.outputs.release_branch }}
_RELEASE_URL: ${{ steps.update_release_description.outputs.release_url }}
run: |
echo "# :fish_cake: Release ready at:" >> $GITHUB_STEP_SUMMARY
echo "$_RELEASE_URL" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
{
echo "# :fish_cake: Release ready at:"
echo "$_RELEASE_URL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$_VERSION_NAME" == "0.0.0" || "$_VERSION_NUMBER" == "0" ]]; then
echo "> [!CAUTION]" >> $GITHUB_STEP_SUMMARY
echo "> Version name or number wasn't previously found and a default value was used. You'll need to manually update the release Title, Tag and Description, specifically, the "Full Changelog" link." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
{
echo "> [!CAUTION]"
echo "> Version name or number wasn't previously found and a default value was used. You'll need to manually update the release Title, Tag and Description, specifically, the \"Full Changelog\" link."
echo ""
} >> "$GITHUB_STEP_SUMMARY"
fi
echo ":clipboard: Confirm that the defined GitHub Release options are correct:" >> $GITHUB_STEP_SUMMARY
echo " * :bookmark: New tag name: \`$_RELEASE_TAG\`" >> $GITHUB_STEP_SUMMARY
echo " * :palm_tree: Target branch: \`$_RELEASE_BRANCH\`" >> $GITHUB_STEP_SUMMARY
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`" >> $GITHUB_STEP_SUMMARY
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch" >> $GITHUB_STEP_SUMMARY
echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes." >> $GITHUB_STEP_SUMMARY
{
echo ":clipboard: Confirm that the defined GitHub Release options are correct:"
echo " * :bookmark: New tag name: \`$_RELEASE_TAG\`"
echo " * :palm_tree: Target branch: \`$_RELEASE_BRANCH\`"
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
echo "> [!NOTE]"
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,23 @@
name: Publish Authenticator GitHub Release as newest
on:
workflow_dispatch:
schedule:
- cron: '0 * * * 1-5' # Every hour on the hour on weekdays
permissions:
contents: write
id-token: write
actions: write
jobs:
publish-release-authenticator:
name: Publish Authenticator Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Authenticator"
workflow_name: "publish-github-release-bwa.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@@ -0,0 +1,24 @@
name: Publish Password Manager GitHub Release as newest
on:
workflow_dispatch:
schedule:
- cron: '0 * * * 1-5' # Every hour on the hour on weekdays
permissions:
contents: write
id-token: write
actions: write
jobs:
publish-release-password-manager:
name: Publish Password Manager Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Password Manager"
workflow_name: "publish-github-release-bwpm.yml"
credentials_filename: "play_creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit

View File

@@ -1,36 +0,0 @@
name: Publish Password Manager and Authenticator GitHub Release as newest
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * 1-5'
permissions:
contents: write
id-token: write
actions: read
jobs:
publish-release-password-manager:
name: Publish Password Manager Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Password Manager"
workflow_name: "publish-github-release.yml"
credentials_filename: "play_creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit
publish-release-authenticator:
name: Publish Authenticator Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Authenticator"
workflow_name: "publish-github-release.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@@ -1,5 +1,6 @@
name: Publish to Google Play
run-name: "Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}"
run-name: >
${{ inputs.dry-run && ' (Dry Run)' || '' }}Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}
on:
workflow_dispatch:
inputs:
@@ -17,15 +18,15 @@ on:
required: true
type: string
rollout-percentage:
description: "Percentage of users who will receive this version update."
required: true
type: choice
options:
- 10%
- 30%
- 50%
- 100%
default: 10%
description: "Percentage of users who will receive this version update."
required: true
type: choice
options:
- 10%
- 30%
- 50%
- 100%
default: 10%
release-notes:
description: "Change notes to be included with this release."
type: string
@@ -46,6 +47,10 @@ on:
- production
- Fastlane Automation Target
required: true
dry-run:
description: "Dry-Run, Run the workflow without publishing to the store"
type: boolean
default: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@@ -54,107 +59,132 @@ permissions:
contents: read
packages: read
id-token: write
actions: write
jobs:
promote:
runs-on: ubuntu-24.04
name: Promote build to Production in Play Store
promote:
runs-on: ubuntu-24.04
name: Promote build to Production in Play Store
steps:
- name: Log inputs to job summary
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
- name: Retrieve secrets
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
mkdir -p ${{ github.workspace }}/app/src/standardRelease
- name: Retrieve secrets
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
mkdir -p ${{ github.workspace }}/app/src/standardRelease
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Format Release Notes
env:
RELEASE_NOTES: ${{ inputs.release-notes }}
run: |
FORMATTED_MESSAGE="$(echo "$RELEASE_NOTES" | sed 's/ /\n/g')"
{
echo "RELEASE_NOTES<<EOF"
printf '%s\n' "$FORMATTED_MESSAGE"
echo "EOF"
} >> "$GITHUB_ENV"
- name: Format Release Notes
run: |
FORMATTED_MESSAGE="$(echo "${{ inputs.release-notes }}" | sed 's/ /\n/g')"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$FORMATTED_MESSAGE" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Promote Play Store version to production
env:
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
VERSION_CODE_INPUT: ${{ inputs.version-code }}
VERSION_NAME: ${{inputs.version-name}}
ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }}
PRODUCT: ${{ inputs.product }}
TRACK_FROM: ${{ inputs.track-from }}
TRACK_TARGET: ${{ inputs.track-target }}
run: |
if [ "$PRODUCT" = "Password Manager" ]; then
PACKAGE_NAME="com.x8bit.bitwarden"
elif [ "$PRODUCT" = "Authenticator" ]; then
PACKAGE_NAME="com.bitwarden.authenticator"
else
echo "Unsupported product: $PRODUCT"
exit 1
fi
- name: Promote Play Store version to production
env:
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
VERSION_CODE_INPUT: ${{ inputs.version-code }}
VERSION_NAME: ${{inputs.version-name}}
ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }}
PRODUCT: ${{ inputs.product }}
TRACK_FROM: ${{ inputs.track-from }}
TRACK_TARGET: ${{ inputs.track-target }}
run: |
if [ "$PRODUCT" = "Password Manager" ]; then
PACKAGE_NAME="com.x8bit.bitwarden"
elif [ "$PRODUCT" = "Authenticator" ]; then
PACKAGE_NAME="com.bitwarden.authenticator"
else
echo "Unsupported product: $PRODUCT"
exit 1
fi
VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',')
VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',')
decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc)
decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc)
bundle exec fastlane updateReleaseNotes \
releaseNotes:"$RELEASE_NOTES" \
versionCode:"$VERSION_CODE" \
packageName:"$PACKAGE_NAME"
bundle exec fastlane updateReleaseNotes \
releaseNotes:"$RELEASE_NOTES" \
versionCode:"$VERSION_CODE" \
packageName:"$PACKAGE_NAME"
bundle exec fastlane promoteToProduction \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME" \
rolloutPercentage:"$decimal" \
packageName:"$PACKAGE_NAME" \
releaseNotes:"$RELEASE_NOTES" \
track:"$TRACK_FROM" \
trackPromoteTo:"$TRACK_TARGET"
bundle exec fastlane promoteToProduction \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME" \
rolloutPercentage:"$decimal" \
packageName:"$PACKAGE_NAME" \
releaseNotes:"$RELEASE_NOTES" \
track:"$TRACK_FROM" \
trackPromoteTo:"$TRACK_TARGET"
- name: Enable Publish Github Release Workflow
env:
PRODUCT: ${{ inputs.product }}
run: |
if ${{ inputs.dry-run }} ; then
gh workflow view publish-github-release-bwpm.yml
exit 0
fi
if [ "$PRODUCT" = "Password Manager" ]; then
gh workflow enable publish-github-release-bwpm.yml
elif [ "$PRODUCT" = "Authenticator" ]; then
gh workflow enable publish-github-release-bwa.yml
fi

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
release_type:
description: 'Release Type'
description: "Release Type"
required: true
type: choice
options:
@@ -22,9 +22,10 @@ jobs:
actions: write
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: true
- name: Create RC or Test Branch
id: rc_branch
@@ -42,10 +43,10 @@ jobs:
branch_name="release/${branch_name}"
git switch main
git switch -c $branch_name
git push origin $branch_name
echo "# :cherry_blossom: ${_RELEASE_TYPE} branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
git switch -c "$branch_name"
git push origin "$branch_name"
echo "# :cherry_blossom: ${_RELEASE_TYPE} branch: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
- name: Create Hotfix Branch
id: hotfix_branch
@@ -66,14 +67,14 @@ jobs:
fi
branch_name="release/hotfix-${latest_tag}"
echo "🌿 branch name: $branch_name"
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
if git show-ref --verify --quiet "refs/remotes/origin/$branch_name"; then
echo "# :fire: :warning: Hotfix branch already exists: ${branch_name}" >> $GITHUB_STEP_SUMMARY
echo "# :fire: :warning: Hotfix branch already exists: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
git switch -c $branch_name $latest_tag
git push origin $branch_name
echo "# :fire: Hotfix branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
git switch -c "$branch_name" "$latest_tag"
git push origin "$branch_name"
echo "# :fire: Hotfix branch: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
- name: Trigger CI Workflows
env:
@@ -81,5 +82,5 @@ jobs:
_BRANCH_NAME: ${{ steps.rc_branch.outputs.branch_name || steps.hotfix_branch.outputs.branch_name }}
run: |
echo "🌿 branch name: $_BRANCH_NAME"
gh workflow run build.yml --ref $_BRANCH_NAME -f distribute-to-firebase=true -f publish-to-play-store=true
gh workflow run build-authenticator.yml --ref $_BRANCH_NAME -f distribute-to-firebase=true -f publish-to-play-store=true
gh workflow run build.yml --ref "$_BRANCH_NAME" -f distribute-to-firebase=true -f publish-to-play-store=true
gh workflow run build-authenticator.yml --ref "$_BRANCH_NAME" -f distribute-to-firebase=true -f publish-to-play-store=true

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

20
.github/workflows/review-code.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
review:
name: Review
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
id-token: write
pull-requests: write

View File

@@ -6,7 +6,7 @@ on:
types: [opened, synchronize, reopened]
branches-ignore:
- main
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, synchronize, reopened]
branches:
- main

View File

@@ -22,6 +22,10 @@ on:
pr-id:
description: "Pull Request ID"
env:
_BOT_NAME: "bw-ghapp[bot]"
_BOT_EMAIL: "178206702+bw-ghapp[bot]@users.noreply.github.com"
jobs:
update:
name: Update and PR
@@ -54,11 +58,16 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-pull-requests: write
permission-actions: read
permission-contents: write
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
persist-credentials: true
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
@@ -69,18 +78,61 @@ jobs:
id: switch-branch
run: |
BRANCH_NAME="sdlc/sdk-update"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
git switch -c $BRANCH_NAME
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Get current SDK version
if git switch "$BRANCH_NAME"; then
echo "✅ Switched to existing branch: $BRANCH_NAME"
echo "updating_existing_branch=true" >> "$GITHUB_OUTPUT"
else
echo "📝 Creating new branch: $BRANCH_NAME"
git switch -c "$BRANCH_NAME"
echo "updating_existing_branch=false" >> "$GITHUB_OUTPUT"
fi
- name: Prevent updating the branch when the last committer isn't the bot
if: ${{ steps.switch-branch.outputs.updating_existing_branch == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
run: |
LATEST_COMMIT_AUTHOR=$(git log -1 --format='%ae' "$_BRANCH_NAME")
echo "Latest commit author in branch ($_BRANCH_NAME): $LATEST_COMMIT_AUTHOR"
echo "Expected bot email: $_BOT_EMAIL"
if [ "$LATEST_COMMIT_AUTHOR" != "$_BOT_EMAIL" ]; then
echo "::error::Branch $_BRANCH_NAME has a commit not made by the bot." \
"This indicates manual changes have been made to the branch," \
"PR has to be merged or closed before running this workflow again."
echo "👀 Fetching existing PR..."
gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty'
EXISTING_PR=$(gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty')
if [ -z "$EXISTING_PR" ]; then
echo "::error::Couldn't find an existing PR for branch $_BRANCH_NAME."
exit 1
fi
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
echo "## ❌ Merge or close: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "✅ Branch tip commit was made by the bot. Safe to proceed."
# Using main to retrieve the changelog on consecutive updates of the same PR.
- name: Get current SDK version from main branch
id: get-current-sdk
run: |
SDK_VERSION=$(grep "bitwardenSdk =" gradle/libs.versions.toml | cut -d'"' -f2)
git show origin/main:gradle/libs.versions.toml
SDK_VERSION=$(git show origin/main:gradle/libs.versions.toml | grep "bitwardenSdk =" | cut -d'"' -f2)
if [ -z "$SDK_VERSION" ]; then
echo "::error::Failed to get current SDK version from main branch."
exit 1
fi
GIT_REF=$(echo "$SDK_VERSION" | cut -d'-' -f3-) # handles both commit hashes and branch names
echo "Current SDK version: $SDK_VERSION"
echo "Current SDK version (from main): $SDK_VERSION"
echo "Current SDK git ref: $GIT_REF"
echo "version=$SDK_VERSION" >> $GITHUB_OUTPUT
echo "git_ref=$GIT_REF" >> $GITHUB_OUTPUT
echo "version=$SDK_VERSION" >> "$GITHUB_OUTPUT"
echo "git_ref=$GIT_REF" >> "$GITHUB_OUTPUT"
- name: Update SDK Version
env:
@@ -97,14 +149,14 @@ jobs:
run: |
echo "👀 Committing SDK version update..."
git config user.name "bw-ghapp[bot]"
git config user.email "178206702+bw-ghapp[bot]@users.noreply.github.com"
git config user.name "$_BOT_NAME"
git config user.email "$_BOT_EMAIL"
git add gradle/libs.versions.toml
git commit -m "SDK Update - $_SDK_PACKAGE $_SDK_VERSION"
git push origin $_BRANCH_NAME
git push origin "$_BRANCH_NAME"
- name: Create Pull Request
- name: Create or Update Pull Request
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
@@ -121,17 +173,26 @@ jobs:
$CHANGELOG"
# Use echo -e to interpret escape sequences and pipe to gh pr create
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
--title "Update SDK to $_SDK_VERSION" \
--body-file - \
--base main \
--head $_BRANCH_NAME \
--label "automated-pr" \
--label "t:ci")
EXISTING_PR=$(gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty')
echo "🚀 Created PR: $PR_URL"
echo "## 🚀 Created PR: $PR_URL" >> $GITHUB_STEP_SUMMARY
if [ -n "$EXISTING_PR" ]; then
echo "🔄 Updating existing PR #$EXISTING_PR..."
echo -e "$PR_BODY" | gh pr edit "$EXISTING_PR" \
--title "Update SDK to $_SDK_VERSION" \
--body-file -
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
echo "## ✅ Updated PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
else
echo "📝 Creating new PR..."
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
--title "Update SDK to $_SDK_VERSION" \
--body-file - \
--base main \
--head "$_BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci")
echo "## 🚀 Created PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
fi
test:
name: Test Update
@@ -143,7 +204,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs

View File

@@ -13,10 +13,9 @@ on:
workflow_dispatch:
env:
_JAVA_VERSION: 17
_JAVA_VERSION: 21
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
jobs:
test:
name: Test
@@ -27,10 +26,12 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
@@ -52,12 +53,12 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env._JAVA_VERSION }}
@@ -91,7 +92,7 @@ jobs:
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:
@@ -103,14 +104,14 @@ jobs:
- name: Comment PR if tests failed
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
env:
PR_NUMBER: ${{ github.event.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ACTOR: ${{ github.triggering_actor }}
PR_NUMBER: ${{ github.event.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ACTOR: ${{ github.triggering_actor }}
run: |
echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> $GITHUB_STEP_SUMMARY
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
if [ -n "$PR_NUMBER" ]; then
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
gh pr comment --repo $GITHUB_REPOSITORY $PR_NUMBER --body "$message"
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
fi

5
.github/zizmor.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
rules:
unpinned-uses:
config:
policies:
bitwarden/gh-actions/*: ref-pin

View File

@@ -11,8 +11,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1139.0)
aws-sdk-core (3.228.0)
aws-partitions (1.1177.0)
aws-sdk-core (3.235.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,18 +20,18 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.109.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-kms (1.115.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.195.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-s3 (1.201.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.2.2)
bigdecimal (3.3.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -169,7 +169,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.13.2)
json (2.15.2)
jwt (2.10.2)
base64
logger (1.7.0)
@@ -192,15 +192,15 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.20.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList

View File

@@ -4,13 +4,12 @@
- [Compatibility](#compatibility)
- [Setup](#setup)
- [Theme](#theme)
- [Dependencies](#dependencies)
## Compatibility
- **Minimum SDK**: 29
- **Target SDK**: 35
- **Minimum SDK**: 29 (Android 10)
- **Target SDK**: 36 (Android 16)
- **Device Types Supported**: Phone and Tablet
- **Orientations Supported**: Portrait and Landscape
@@ -52,12 +51,12 @@
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
4. Setup JDK `Version` `17`:
4. Setup JDK `Version` `21`:
- Navigate to `Preferences > Build, Execution, Deployment > Build Tools > Gradle`.
- Hit the selected Gradle JDK next to `Gradle JDK:`.
- Select a `17.x` version or hit `Download JDK...` if not present.
- Select `Version` `17`.
- Select a `21.x` version or hit `Download JDK...` if not present.
- Select `Version` `21`.
- Select your preferred `Vendor`.
- Hit `Download`.
- Hit `Apply`.
@@ -93,25 +92,6 @@ chmod +x .git/hooks/pre-commit
echo "detekt pre-commit hook installed successfully to .git/hooks/pre-commit"
```
## Theme
### Icons & Illustrations
The app supports light mode, dark mode and dynamic colors. Most icons in the app will display correctly using tinting but multi-tonal icons and illustrations require extra processing in order to be displayed properly with dynamic colors.
All illustrations and multi-tonal icons require the svg paths to be tagged with the `name` attribute in order for each individual path to be tinted the appropriate color. Any untagged path will not be tinted and the resulting image will be incorrect.
The supported tags are as follows:
* outline
* primary
* secondary
* tertiary
* accent
* logo
* navigation
* navigationActiveAccent
## Dependencies
### Application Dependencies

View File

@@ -224,6 +224,7 @@ dependencies {
implementation(project(":annotation"))
implementation(project(":core"))
implementation(project(":cxf"))
implementation(project(":data"))
implementation(project(":network"))
implementation(project(":ui"))
@@ -234,8 +235,6 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.biometrics)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3)
@@ -245,6 +244,8 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.providerevents)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -258,7 +259,6 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.androidx.credentials)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
@@ -269,7 +269,6 @@ dependencies {
implementation(platform(libs.square.retrofit.bom))
implementation(libs.square.retrofit)
implementation(libs.timber)
implementation(libs.zxing.zxing.core)
// For now we are restricted to running Compose tests for debug builds only
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -290,7 +289,7 @@ dependencies {
testImplementation(libs.google.hilt.android.testing)
testImplementation(platform(libs.junit.bom))
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.junit.junit5)
testImplementation(libs.junit.jupiter)
testImplementation(libs.junit.vintage)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.mockk)
@@ -303,7 +302,10 @@ tasks {
useJUnitPlatform()
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" + "-Duser.country=US"
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
}

View File

@@ -0,0 +1,264 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "11387825dab701f9d2dd2e940ffbd794",
"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, 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
}
],
"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`)"
}
]
},
{
"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, '11387825dab701f9d2dd2e940ffbd794')"
]
}
}

View File

@@ -105,6 +105,17 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="bitwarden" />
</intent-filter>
<!-- Handle Credential Exchange transfer requests -->
<intent-filter
android:autoVerify="true"
tools:ignore="AppLinkUrlError">
<action android:name="androidx.identitycredentials.action.IMPORT_CREDENTIALS" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:mimeType="application/octet-stream"
android:scheme="content"
tools:ignore="AppLinkUriRelativeFilterGroupError" />
</intent-filter>
</activity>
<activity
@@ -249,7 +260,7 @@
android:name="com.x8bit.bitwarden.AutofillTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/autofill"
android:label="@string/autofill_title"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>

View File

@@ -48,6 +48,18 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "org.ironfoxoss.ironfox.nightly",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C5:E2:91:B5:A5:71:F9:C8:CD:9A:97:99:C2:C9:4E:02:EC:97:03:94:88:93:F2:CA:75:6D:67:B9:42:04:F9:04"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -11,6 +11,7 @@ import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.auth.AuthTabIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -38,6 +39,7 @@ import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScre
import com.x8bit.bitwarden.ui.platform.feature.rootnav.ROOT_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.util.appLanguage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.map
@@ -68,6 +70,16 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
private val duoLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.DuoResult(it))
}
private val ssoLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.SsoResult(it))
}
private val webAuthnLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.WebAuthnResult(it))
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -88,7 +100,14 @@ class MainActivity : AppCompatActivity() {
SetupEventsEffect(navController = navController)
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider(featureFlagsState = state.featureFlagsState) {
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = AuthTabLaunchers(
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
),
) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
{ mainViewModel.trySendAction(MainAction.ResumeScreenDataReceived(it)) }

View File

@@ -2,17 +2,23 @@ package com.x8bit.bitwarden
import android.content.Intent
import android.os.Parcelable
import androidx.browser.auth.AuthTabIntent
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.share.ShareManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
@@ -75,7 +81,7 @@ class MainViewModel @Inject constructor(
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val intentManager: IntentManager,
private val shareManager: ShareManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
@@ -179,6 +185,9 @@ class MainViewModel @Inject constructor(
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
is MainAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action)
is MainAction.DuoResult -> handleDuoResult(action)
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -207,6 +216,20 @@ class MainViewModel @Inject constructor(
settingsRepository.appLanguage = action.appLanguage
}
private fun handleDuoResult(action: MainAction.DuoResult) {
authRepository.setDuoCallbackTokenResult(
tokenResult = action.authResult.getDuoCallbackTokenResult(),
)
}
private fun handleSsoResult(action: MainAction.SsoResult) {
authRepository.setSsoCallbackResult(result = action.authResult.getSsoCallbackResult())
}
private fun handleWebAuthnResult(action: MainAction.WebAuthnResult) {
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -272,7 +295,7 @@ class MainViewModel @Inject constructor(
val passwordlessRequestData = intent.getPasswordlessRequestDataIntentOrNull()
val autofillSaveItem = intent.getAutofillSaveItemOrNull()
val autofillSelectionData = intent.getAutofillSelectionDataOrNull()
val shareData = intentManager.getShareDataFromIntent(intent)
val shareData = shareManager.getShareDataOrNull(intent = intent)
val totpData: TotpData? =
// First grab TOTP URI directly from the intent data:
intent.getTotpDataOrNull()
@@ -295,6 +318,7 @@ class MainViewModel @Inject constructor(
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@@ -418,6 +442,16 @@ class MainViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AccountSecurityShortcut
}
importCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
requestJson = importCredentialsRequest.request.requestJson,
),
)
}
}
}
@@ -485,6 +519,21 @@ data class MainState(
* Models actions for the [MainActivity].
*/
sealed class MainAction {
/**
* Receive the result from the Duo login flow.
*/
data class DuoResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
/**
* Receive the result from the SSO login flow.
*/
data class SsoResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
/**
* Receive the result from the WebAuthn login flow.
*/
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
/**
* Receive first Intent by the application.
*/

View File

@@ -216,25 +216,59 @@ interface AuthDiskSource : AppIdProvider {
/**
* Retrieves a pin-protected user key for the given [userId].
*/
@Deprecated(
message = "Use getPinProtectedUserKeyEnvelope instead.",
replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelope"),
)
fun getPinProtectedUserKey(userId: String): String?
/**
* Retrieves a pin-protected user key envelope for the given [userId].
*/
fun getPinProtectedUserKeyEnvelope(userId: String): String?
/**
* Stores a pin-protected user key for the given [userId].
*
* When [inMemoryOnly] is `true`, the value will only be available via a call to
* [getPinProtectedUserKey] during the current app session.
*/
@Deprecated(
message = "Use storePinProtectedUserKeyEnvelope instead.",
replaceWith = ReplaceWith("storePinProtectedUserKeyEnvelope"),
)
fun storePinProtectedUserKey(
userId: String,
pinProtectedUserKey: String?,
inMemoryOnly: Boolean = false,
)
/**
* Stores a pin-protected user key envelope for the given [userId].
*
* When [inMemoryOnly] is `true`, the value will only be available via a call to
* [getPinProtectedUserKeyEnvelope] during the current app session.
*/
fun storePinProtectedUserKeyEnvelope(
userId: String,
pinProtectedUserKeyEnvelope: String?,
inMemoryOnly: Boolean = false,
)
/**
* Retrieves a flow for the pin-protected user key for the given [userId].
*/
@Deprecated(
message = "Use getPinProtectedUserKeyEnvelopeFlow instead.",
replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelopeFlow"),
)
fun getPinProtectedUserKeyFlow(userId: String): Flow<String?>
/**
* Retrieves a flow for the pin-protected user key envelope for the given [userId].
*/
fun getPinProtectedUserKeyEnvelopeFlow(userId: String): Flow<String?>
/**
* Gets a two-factor auth token using a user's [email].
*/

View File

@@ -37,6 +37,7 @@ private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
private const val ENCRYPTED_PIN_KEY = "protectedPin"
private const val ORGANIZATIONS_KEY = "organizations"
private const val ORGANIZATION_KEYS_KEY = "encOrgKeys"
@@ -67,6 +68,7 @@ class AuthDiskSourceImpl(
AuthDiskSource {
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val inMemoryPinProtectedUserKeyEnvelopes = mutableMapOf<String, String?>()
private val mutableShouldUseKeyConnectorFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableOrganizationsFlowMap =
@@ -82,6 +84,8 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutablePinProtectedUserKeyFlowMap =
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutablePinProtectedUserKeyEnvelopeFlowMap =
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
override var userState: UserStateJson?
@@ -142,6 +146,7 @@ class AuthDiskSourceImpl(
storeUserKey(userId = userId, userKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null)
storeEncryptedPin(userId = userId, encryptedPin = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
@@ -329,10 +334,24 @@ class AuthDiskSourceImpl(
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
@Deprecated(
"Use getPinProtectedUserKeyEnvelope instead.",
replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelope"),
)
override fun getPinProtectedUserKey(userId: String): String? =
inMemoryPinProtectedUserKeys[userId]
?: getString(key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId))
override fun getPinProtectedUserKeyEnvelope(userId: String): String? =
inMemoryPinProtectedUserKeyEnvelopes[userId]
?: getString(
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
)
@Deprecated(
"Use storePinProtectedUserKeyEnvelope instead.",
replaceWith = ReplaceWith("storePinProtectedUserKeyEnvelope"),
)
override fun storePinProtectedUserKey(
userId: String,
pinProtectedUserKey: String?,
@@ -347,10 +366,32 @@ class AuthDiskSourceImpl(
getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey)
}
override fun storePinProtectedUserKeyEnvelope(
userId: String,
pinProtectedUserKeyEnvelope: String?,
inMemoryOnly: Boolean,
) {
inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope
if (inMemoryOnly) return
putString(
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
value = pinProtectedUserKeyEnvelope,
)
getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope)
}
@Deprecated(
"Use getPinProtectedUserKeyEnvelopeFlow instead.",
replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelopeFlow"),
)
override fun getPinProtectedUserKeyFlow(userId: String): Flow<String?> =
getMutablePinProtectedUserKeyFlow(userId)
.onSubscription { emit(getPinProtectedUserKey(userId = userId)) }
override fun getPinProtectedUserKeyEnvelopeFlow(userId: String): Flow<String?> =
getMutablePinProtectedUserKeyEnvelopeFlow(userId)
.onSubscription { emit(getPinProtectedUserKeyEnvelope(userId = userId)) }
override fun getTwoFactorToken(email: String): String? =
getString(key = TWO_FACTOR_TOKEN_KEY.appendIdentifier(email))
@@ -579,6 +620,12 @@ class AuthDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePinProtectedUserKeyEnvelopeFlow(
userId: String,
): MutableSharedFlow<String?> = mutablePinProtectedUserKeyEnvelopeFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun migrateAccountTokens() {
userState
?.accounts

View File

@@ -27,6 +27,12 @@ enum class OnboardingStatus {
@SerialName("autofillSetup")
AUTOFILL_SETUP,
/**
* The user is completing the browser autofill service setup.
*/
@SerialName("browserAutofillSetup")
BROWSER_AUTOFILL_SETUP,
/**
* The user is completing the final step of the onboarding process.
*/

View File

@@ -1,9 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.KdfJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KdfTypeJson.ARGON2_ID
import com.bitwarden.network.model.KdfTypeJson.PBKDF2_SHA256
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_ARGON2_MEMORY
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM
/**
* Convert a [Kdf] to a [KdfTypeJson].
@@ -13,3 +16,36 @@ fun Kdf.toKdfTypeJson(): KdfTypeJson =
is Kdf.Argon2id -> ARGON2_ID
is Kdf.Pbkdf2 -> PBKDF2_SHA256
}
/**
* Convert a [Kdf] to [KdfJson]
*/
fun Kdf.toKdfRequestModel(): KdfJson =
when (this) {
is Kdf.Argon2id -> KdfJson(
kdfType = toKdfTypeJson(),
iterations = iterations.toInt(),
memory = memory.toInt(),
parallelism = parallelism.toInt(),
)
is Kdf.Pbkdf2 -> KdfJson(
kdfType = toKdfTypeJson(),
iterations = iterations.toInt(),
memory = null,
parallelism = null,
)
}
/**
* Convert a [KdfJson] to a [Kdf].
*/
fun KdfJson.toKdf(): Kdf =
when (this.kdfType) {
ARGON2_ID -> Kdf.Argon2id(
iterations = iterations.toUInt(),
memory = memory?.toUInt() ?: DEFAULT_ARGON2_MEMORY.toUInt(),
parallelism = parallelism?.toUInt() ?: DEFAULT_ARGON2_PARALLELISM.toUInt(),
)
PBKDF2_SHA256 -> Kdf.Pbkdf2(iterations = iterations.toUInt())
}

View File

@@ -10,19 +10,20 @@ class AuthTokenManagerImpl(
private val authDiskSource: AuthDiskSource,
) : AuthTokenManager {
override fun getAuthTokenDataOrNull(userId: String): AuthTokenData? =
authDiskSource
.getAccountTokens(userId = userId)
?.takeIf { it.accessToken != null }
?.let {
AuthTokenData(
userId = userId,
accessToken = requireNotNull(it.accessToken),
expiresAtSec = it.expiresAtSec,
)
}
override fun getAuthTokenDataOrNull(): AuthTokenData? = authDiskSource
.userState
?.activeUserId
?.let { userId ->
authDiskSource
.getAccountTokens(userId = userId)
?.takeIf { it.accessToken != null }
?.let {
AuthTokenData(
userId = userId,
accessToken = requireNotNull(it.accessToken),
expiresAtSec = it.expiresAtSec,
)
}
}
?.let(::getAuthTokenDataOrNull)
}

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
/**
* An interface to manage the user KDF settings.
*/
interface KdfManager {
/**
* Checks if user's current KDF settings are below the minimums and needs update
*/
fun needsKdfUpdateToMinimums(): Boolean
/**
* Updates the user's KDF settings if below the minimums
*/
suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult
}

View File

@@ -0,0 +1,103 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordAuthenticationDataJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.UpdateKdfJsonRequest
import com.bitwarden.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonKdfUpdatedMinimums
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import timber.log.Timber
import kotlin.collections.get
/**
* Default implementation of [KdfManager].
*/
class KdfManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val accountsService: AccountsService,
private val featureFlagManager: FeatureFlagManager,
) : KdfManager {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
override fun needsKdfUpdateToMinimums(): Boolean {
if (!featureFlagManager.getFeatureFlag(FlagKey.ForceUpdateKdfSettings)) {
return false
}
val account = authDiskSource
.userState
?.accounts
?.get(activeUserId)
?: return false
if (account.profile.userDecryptionOptions != null &&
!account.profile.userDecryptionOptions.hasMasterPassword
) {
return false
}
return account.profile.kdfType == KdfTypeJson.PBKDF2_SHA256 &&
account.profile.kdfIterations != null &&
account.profile.kdfIterations < DEFAULT_PBKDF2_ITERATIONS
}
override suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult {
val userId = activeUserId ?: return UpdateKdfMinimumsResult.ActiveAccountNotFound
if (!needsKdfUpdateToMinimums()) {
return UpdateKdfMinimumsResult.Success
}
return vaultSdkSource
.makeUpdateKdf(
userId = userId,
password = password,
kdf = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt()),
)
.flatMap {
accountsService.updateKdf(createUpdateKdfRequest(it))
}
.fold(
onSuccess = {
authDiskSource.userState = authDiskSource.userState
?.toUserStateJsonKdfUpdatedMinimums()
Timber.d("[Auth] Upgraded user's KDF to minimums")
UpdateKdfMinimumsResult.Success
},
onFailure = { UpdateKdfMinimumsResult.Error(error = it) },
)
}
private fun createUpdateKdfRequest(response: UpdateKdfResponse): UpdateKdfJsonRequest {
val authData = response.masterPasswordAuthenticationData
val oldAuthData = response.oldMasterPasswordAuthenticationData
val unlockData = response.masterPasswordUnlockData
return UpdateKdfJsonRequest(
authenticationData = MasterPasswordAuthenticationDataJson(
kdf = authData.kdf.toKdfRequestModel(),
masterPasswordAuthenticationHash = authData.masterPasswordAuthenticationHash,
salt = authData.salt,
),
key = unlockData.masterKeyWrappedUserKey,
masterPasswordHash = oldAuthData.masterPasswordAuthenticationHash,
newMasterPasswordHash = authData.masterPasswordAuthenticationHash,
unlockData = MasterPasswordUnlockDataJson(
kdf = unlockData.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = unlockData.masterKeyWrappedUserKey,
salt = unlockData.salt,
),
)
}
}

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -34,10 +35,12 @@ class UserLogoutManagerImpl(
private val toastManager: ToastManager,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
dispatcherManager: DispatcherManager,
) : UserLogoutManager {
private val scope = CoroutineScope(dispatcherManager.unconfined)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mainScope = CoroutineScope(dispatcherManager.main)
private val ioScope = CoroutineScope(dispatcherManager.io)
private val mutableLogoutEventFlow: MutableSharedFlow<LogoutEvent> =
bufferedMutableSharedFlow()
@@ -58,8 +61,10 @@ class UserLogoutManagerImpl(
)
if (!ableToSwitchToNewAccount) {
// Update the user information and log out
// Update the user information and log out.
authDiskSource.userState = null
// Unregister the application from CXP Export since there are no other accounts.
ioScope.launch { credentialExchangeRegistryManager.unregister() }
}
clearData(userId = userId)
@@ -80,7 +85,8 @@ class UserLogoutManagerImpl(
// Save any data that will still need to be retained after otherwise clearing all dat
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId)
val pinProtectedUserKeyEnvelope = authDiskSource
.getPinProtectedUserKeyEnvelope(userId = userId)
switchUserIfAvailable(
currentUserId = userId,
@@ -102,9 +108,9 @@ class UserLogoutManagerImpl(
vaultTimeoutAction = vaultTimeoutAction,
)
}
authDiskSource.storePinProtectedUserKey(
authDiskSource.storePinProtectedUserKeyEnvelope(
userId = userId,
pinProtectedUserKey = pinProtectedUserKey,
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
)
}
@@ -114,7 +120,7 @@ class UserLogoutManagerImpl(
generatorDiskSource.clearData(userId = userId)
pushDiskSource.clearData(userId = userId)
settingsDiskSource.clearData(userId = userId)
scope.launch {
unconfinedScope.launch {
passwordHistoryDiskSource.clearPasswordHistories(userId = userId)
vaultDiskSource.deleteVaultData(userId = userId)
}

View File

@@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the global state of all users.
*/
interface UserStateManager {
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Emits updates for changes to the [UserState.hasPendingAccountAddition] flag.
*/
val hasPendingAccountAdditionStateFlow: StateFlow<Boolean>
/**
* Tracks whether there is an account that is pending deletion in order to allow the account to
* remain active until the deletion is finalized.
*/
var hasPendingAccountDeletion: Boolean
/**
* Run the given [block] while preventing any updates to [UserState]. This is useful in cases
* where many individual changes might occur that would normally affect the [UserState] but we
* only want a single final emission. In the rare case that multiple threads are running
* transactions simultaneously, there will be no [UserState] updates until the last
* transaction completes.
*/
suspend fun <T> userStateTransaction(block: suspend () -> T): T
}

View File

@@ -0,0 +1,176 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/**
* The default implementation of the [UserStateManager].
*/
class UserStateManagerImpl(
private val authDiskSource: AuthDiskSource,
firstTimeActionManager: FirstTimeActionManager,
vaultLockManager: VaultLockManager,
private val policyManager: PolicyManager,
dispatcherManager: DispatcherManager,
) : UserStateManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
//region Pending Account Addition
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(value = false)
override val hasPendingAccountAdditionStateFlow: StateFlow<Boolean>
get() = mutableHasPendingAccountAdditionStateFlow
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
//endregion Pending Account Addition
//region Pending Account Deletion
/**
* If there is a pending account deletion, continue showing the original UserState until it
* is confirmed. This is accomplished by blocking the emissions of the [userStateFlow]
* whenever set to `true`.
*/
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(value = false)
override var hasPendingAccountDeletion: Boolean
by mutableHasPendingAccountDeletionStateFlow::value
//endregion Pending Account Deletion
/**
* Whenever a function needs to update multiple underlying data-points that contribute to the
* [UserState], we update this [MutableStateFlow] and continue to show the original `UserState`
* until the transaction is complete. This is accomplished by blocking the emissions of the
* [userStateFlow] whenever this is set to a value above 0 (a count is used if more than one
* process is updating data simultaneously).
*/
private val mutableUserStateTransactionCountStateFlow = MutableStateFlow(0)
@Suppress("UNCHECKED_CAST", "MagicNumber")
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
firstTimeActionManager.firstTimeStateFlow,
vaultLockManager.vaultUnlockDataStateFlow,
hasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
merge(
mutableHasPendingAccountDeletionStateFlow,
mutableUserStateTransactionCountStateFlow,
vaultLockManager.isActiveUserUnlockingFlow,
),
) { array ->
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val firstTimeState = array[5] as FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
getUserPolicies = ::existingPolicies,
)
}
.filterNot {
mutableHasPendingAccountDeletionStateFlow.value ||
mutableUserStateTransactionCountStateFlow.value > 0 ||
vaultLockManager.isActiveUserUnlockingFlow.value
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultLockManager.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
onboardingStatus = authDiskSource.currentOnboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState,
getUserPolicies = ::existingPolicies,
),
)
override suspend fun <T> userStateTransaction(block: suspend () -> T): T {
mutableUserStateTransactionCountStateFlow.update { it.inc() }
return try {
block()
} finally {
mutableUserStateTransactionCountStateFlow.update { it.dec() }
}
}
private fun isBiometricsEnabled(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isDeviceTrusted(
userId: String,
): Boolean = authDiskSource.getDeviceKey(userId = userId) != null
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType = authDiskSource
.getPinProtectedUserKeyEnvelope(userId = userId)
?.let { VaultUnlockType.PIN }
?: VaultUnlockType.MASTER_PASSWORD
private fun existingPolicies(
userId: String,
policyType: PolicyTypeJson,
): List<SyncResponseJson.Policy> = policyManager.getUserPolicies(
userId = userId,
type = policyType,
)
}

View File

@@ -17,6 +17,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManagerImpl
import com.x8bit.bitwarden.data.auth.manager.KdfManager
import com.x8bit.bitwarden.data.auth.manager.KdfManagerImpl
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
@@ -25,6 +27,8 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
@@ -117,6 +121,7 @@ object AuthManagerModule {
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
dispatcherManager: DispatcherManager,
credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
): UserLogoutManager =
UserLogoutManagerImpl(
authDiskSource = authDiskSource,
@@ -128,6 +133,7 @@ object AuthManagerModule {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager,
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
)
@Provides
@@ -140,4 +146,18 @@ object AuthManagerModule {
fun providesAuthTokenManager(
authDiskSource: AuthDiskSource,
): AuthTokenManager = AuthTokenManagerImpl(authDiskSource = authDiskSource)
@Provides
@Singleton
fun providesKdfManager(
authDiskSource: AuthDiskSource,
vaultSdkSource: VaultSdkSource,
accountsService: AccountsService,
featureFlagManager: FeatureFlagManager,
): KdfManager = KdfManagerImpl(
authDiskSource = authDiskSource,
vaultSdkSource = vaultSdkSource,
accountsService = accountsService,
featureFlagManager = featureFlagManager,
)
}

View File

@@ -26,6 +26,7 @@ fun TrustDeviceResponse.toUserStateJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
val deviceOptions = decryptionOptions
.trustedDeviceUserDecryptionOptions

View File

@@ -6,6 +6,8 @@ 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
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
@@ -27,7 +29,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
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
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
@@ -44,17 +45,16 @@ import kotlinx.coroutines.flow.StateFlow
* Provides an API for observing an modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
interface AuthRepository :
AuthenticatorProvider,
AuthRequestManager,
KdfManager,
UserStateManager {
/**
* Models the current auth state.
*/
val authStateFlow: StateFlow<AuthState>
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Flow of the current [DuoCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setDuoCallbackTokenResult] is called.
@@ -110,15 +110,6 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
var shouldTrustDevice: Boolean
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Return the cached password policies for the current user.
*/
@@ -140,11 +131,6 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val showWelcomeCarousel: Boolean
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/
fun clearPendingAccountDeletion()
/**
* Attempt to delete the current account using the [masterPassword] and log them out
* upon success.

View File

@@ -33,6 +33,8 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
import com.bitwarden.network.service.AccountsService
@@ -46,15 +48,16 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@@ -77,13 +80,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
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
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
@@ -91,50 +90,39 @@ import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
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.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
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
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
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.VaultUnlockData
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.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
@@ -144,7 +132,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.time.Clock
import javax.inject.Singleton
@@ -163,6 +151,7 @@ class AuthRepositoryImpl(
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val configDiskSource: ConfigDiskSource,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
@@ -172,12 +161,15 @@ class AuthRepositoryImpl(
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager {
AuthRequestManager by authRequestManager,
KdfManager by kdfManager,
UserStateManager by userStateManager {
/**
* A scope intended for use when simply collecting multiple flows in order to combine them. The
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
@@ -190,24 +182,6 @@ class AuthRepositoryImpl(
*/
private val ioScope = CoroutineScope(dispatcherManager.io)
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false)
/**
* If there is a pending account deletion, continue showing the original UserState until it
* is confirmed. This is accomplished by blocking the emissions of the [userStateFlow]
* whenever set to `true`.
*/
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(false)
/**
* Whenever a function needs to update multiple underlying data-points that contribute to the
* [UserState], we update this [MutableStateFlow] and continue to show the original `UserState`
* until the transaction is complete. This is accomplished by blocking the emissions of the
* [userStateFlow] whenever this is set to a value above 0 (a count is used if more than one
* process is updating data simultaneously).
*/
private val mutableUserStateTransactionCountStateFlow = MutableStateFlow(0)
/**
* The auth information to make the identity token request will need to be
* cached to make the request again in the case of two-factor authentication.
@@ -268,68 +242,6 @@ class AuthRepositoryImpl(
initialValue = AuthState.Uninitialized,
)
@Suppress("UNCHECKED_CAST", "MagicNumber")
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
firstTimeActionManager.firstTimeStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
merge(
mutableHasPendingAccountDeletionStateFlow,
mutableUserStateTransactionCountStateFlow,
vaultRepository.isActiveUserUnlockingFlow,
),
) { array ->
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val firstTimeState = array[5] as FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
)
}
.filterNot {
mutableHasPendingAccountDeletionStateFlow.value ||
mutableUserStateTransactionCountStateFlow.value > 0 ||
vaultRepository.isActiveUserUnlockingFlow.value
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultRepository.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
onboardingStatus = authDiskSource.currentOnboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState,
),
)
private val duoTokenChannel = Channel<DuoCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val duoTokenResultFlow: Flow<DuoCallbackTokenResult> = duoTokenChannel.receiveAsFlow()
@@ -358,9 +270,6 @@ class AuthRepositoryImpl(
}
}
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
@@ -379,7 +288,7 @@ class AuthRepositoryImpl(
init {
combine(
mutableHasPendingAccountAdditionStateFlow,
userStateManager.hasPendingAccountAdditionStateFlow,
authDiskSource.userStateFlow,
environmentRepository.environmentStateFlow,
) { hasPendingAddition, userState, environment ->
@@ -398,11 +307,21 @@ class AuthRepositoryImpl(
.launchIn(unconfinedScope)
pushManager
.syncOrgKeysFlow
.onEach {
val userId = activeUserId ?: return@onEach
// TODO: [PM-20593] Investigate why tokens are explicitly refreshed.
refreshAccessTokenSynchronously(userId = userId)
vaultRepository.sync(forced = true)
.onEach { userId ->
// This will force the next authenticated request to refresh the auth token.
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = authDiskSource
.getAccountTokens(userId = userId)
?.copy(expiresAtSec = 0L),
)
if (userId == activeUserId) {
// We just sync now to get the latest data
vaultRepository.sync(forced = true)
} else {
// We clear the last sync time to ensure we sync when we become the active user
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
}
}
// This requires the ioScope to ensure that refreshAccessTokenSynchronously
// happens on a background thread
@@ -460,16 +379,12 @@ class AuthRepositoryImpl(
.launchIn(unconfinedScope)
}
override fun clearPendingAccountDeletion() {
mutableHasPendingAccountDeletionStateFlow.value = false
}
override suspend fun deleteAccountWithMasterPassword(
masterPassword: String,
): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error(message = null, error = NoActiveUserException())
mutableHasPendingAccountDeletionStateFlow.value = true
userStateManager.hasPendingAccountDeletion = true
return authSdkSource
.hashPassword(
email = profile.email,
@@ -489,7 +404,7 @@ class AuthRepositoryImpl(
override suspend fun deleteAccountWithOneTimePassword(
oneTimePassword: String,
): DeleteAccountResult {
mutableHasPendingAccountDeletionStateFlow.value = true
userStateManager.hasPendingAccountDeletion = true
return accountsService
.deleteAccount(
masterPasswordHash = null,
@@ -501,13 +416,13 @@ class AuthRepositoryImpl(
private fun Result<DeleteAccountResponseJson>.finalizeDeleteAccount(): DeleteAccountResult =
fold(
onFailure = {
clearPendingAccountDeletion()
userStateManager.hasPendingAccountDeletion = false
DeleteAccountResult.Error(error = it, message = null)
},
onSuccess = { response ->
when (response) {
is DeleteAccountResponseJson.Invalid -> {
clearPendingAccountDeletion()
userStateManager.hasPendingAccountDeletion = false
DeleteAccountResult.Error(message = response.message, error = null)
}
@@ -838,7 +753,17 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onSuccess = {
when (it) {
VerificationCodeResponseJson.Success -> ResendEmailResult.Success
is VerificationCodeResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(
@@ -850,8 +775,18 @@ class AuthRepositoryImpl(
resendNewDeviceOtpRequestJson
?.let { jsonRequest ->
accountsService.resendNewDeviceOtp(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onFailure = { ResendEmailResult.Error(message = null, error = it) },
onSuccess = {
when (it) {
VerificationOtpResponseJson.Success -> ResendEmailResult.Success
is VerificationOtpResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(
@@ -874,7 +809,7 @@ class AuthRepositoryImpl(
// We need to make sure that the environment is set back to the correct spot.
updateEnvironment()
// No switching to do but clear any pending account additions
hasPendingAccountAddition = false
userStateManager.hasPendingAccountAddition = false
return SwitchAccountResult.NoChange
}
@@ -889,7 +824,7 @@ class AuthRepositoryImpl(
authDiskSource.userState = currentUserState.copy(activeUserId = userId)
// Clear any pending account additions
hasPendingAccountAddition = false
userStateManager.hasPendingAccountAddition = false
return SwitchAccountResult.AccountSwitched
}
@@ -1088,12 +1023,6 @@ class AuthRepositoryImpl(
}
.fold(
onSuccess = {
// Clear the password reset reason, since it's no longer relevant.
storeUserResetPasswordReason(
userId = activeAccount.profile.userId,
reason = null,
)
// Update the saved master password hash.
authSdkSource
.hashPassword(
@@ -1109,6 +1038,10 @@ class AuthRepositoryImpl(
)
}
// Log out the user after successful password reset.
// This clears all user state including forcePasswordResetReason.
logout(reason = LogoutReason.PasswordReset)
// Return the success.
ResetPasswordResult.Success
},
@@ -1360,8 +1293,8 @@ class AuthRepositoryImpl(
?.activeAccount
?.profile
?: return ValidatePinResult.Error(error = NoActiveUserException())
val pinProtectedUserKey = authDiskSource
.getPinProtectedUserKey(userId = activeAccount.userId)
val pinProtectedUserKeyEnvelope = authDiskSource
.getPinProtectedUserKeyEnvelope(userId = activeAccount.userId)
?: return ValidatePinResult.Error(
error = MissingPropertyException("Pin Protected User Key"),
)
@@ -1369,7 +1302,7 @@ class AuthRepositoryImpl(
.validatePin(
userId = activeAccount.userId,
pin = pin,
pinProtectedUserKey = pinProtectedUserKey,
pinProtectedUserKey = pinProtectedUserKeyEnvelope,
)
.fold(
onSuccess = { ValidatePinResult.Success(isValid = it) },
@@ -1552,27 +1485,6 @@ class AuthRepositoryImpl(
)
}
private fun isBiometricsEnabled(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isDeviceTrusted(
userId: String,
): Boolean = authDiskSource.getDeviceKey(userId = userId) != null
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType =
when {
authDiskSource.getPinProtectedUserKey(userId = userId) != null -> {
VaultUnlockType.PIN
}
else -> {
VaultUnlockType.MASTER_PASSWORD
}
}
/**
* Update the saved state with the force password reset reason.
*/
@@ -1683,7 +1595,7 @@ class AuthRepositoryImpl(
deviceData: DeviceDataModel?,
orgIdentifier: String?,
userConfirmedKeyConnector: Boolean,
): LoginResult = userStateTransaction {
): LoginResult = userStateManager.userStateTransaction {
val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
@@ -1722,7 +1634,7 @@ class AuthRepositoryImpl(
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
keyConnectorResponse = loginResponse
return LoginResult.ConfirmKeyConnectorDomain(
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
)
}
@@ -1773,6 +1685,16 @@ class AuthRepositoryImpl(
settingsRepository.hasUserLoggedInOrCreatedAccount = true
authDiskSource.userState = userStateJson
password?.let {
// Automatically update kdf to minimums after password unlock and userState update
kdfManager
.updateKdfToMinimumsIfNeeded(password = password)
.also { result ->
if (result is UpdateKdfMinimumsResult.Error) {
Timber.e(result.error, message = "Failed to silent update KDF settings.")
}
}
}
loginResponse.key?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the pending admin auth request.
@@ -1962,15 +1884,27 @@ class AuthRepositoryImpl(
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKeyOrNull() ?: return null
val key = loginResponse.key ?: return null
val initUserCryptoMethod = loginResponse
.userDecryptionOptions
?.masterPasswordUnlock
?.let { masterPasswordUnlock ->
InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
)
}
?: InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
)
return unlockVault(
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
),
initUserCryptoMethod = initUserCryptoMethod,
)
}
@@ -2156,22 +2090,6 @@ class AuthRepositoryImpl(
}
//endregion LoginCommon
/**
* Run the given [block] while preventing any updates to [UserState]. This is useful in cases
* where many individual changes might occur that would normally affect the [UserState] but we
* only want a single final emission. In the rare case that multiple threads are running
* transactions simultaneously, there will be no [UserState] updates until the last
* transaction completes.
*/
private inline fun <T> userStateTransaction(block: () -> T): T {
mutableUserStateTransactionCountStateFlow.update { it.inc() }
return try {
block()
} finally {
mutableUserStateTransactionCountStateFlow.update { it.dec() }
}
}
}
/**

View File

@@ -10,11 +10,15 @@ import com.bitwarden.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManagerImpl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -22,6 +26,7 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@@ -49,6 +54,7 @@ object AuthRepositoryModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
configDiskSource: ConfigDiskSource,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
@@ -60,8 +66,9 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
userStateManager: UserStateManager,
kdfManager: KdfManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@@ -71,6 +78,7 @@ object AuthRepositoryModule {
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
configDiskSource = configDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatcherManager,
@@ -83,7 +91,24 @@ object AuthRepositoryModule {
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
firstTimeActionManager = firstTimeActionManager,
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
)
@Provides
@Singleton
fun providesUserStateManager(
authDiskSource: AuthDiskSource,
firstTimeActionManager: FirstTimeActionManager,
vaultLockManager: VaultLockManager,
policyManager: PolicyManager,
dispatcherManager: DispatcherManager,
): UserStateManager = UserStateManagerImpl(
authDiskSource = authDiskSource,
firstTimeActionManager = firstTimeActionManager,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
dispatcherManager = dispatcherManager,
)
}

View File

@@ -66,6 +66,11 @@ sealed class LogoutReason {
*/
data object Notification : LogoutReason()
/**
* Indicates that the logout is happening because the user reset their master password.
*/
data object PasswordReset : LogoutReason()
/**
* Indicates that the logout is happening because the sync security stamp was invalidated.
*/

View File

@@ -122,6 +122,42 @@ sealed class PolicyInformation {
val minutes: Int?,
@SerialName("action")
val action: String?,
) : PolicyInformation()
val action: Action?,
@SerialName("type")
val type: Type?,
) : PolicyInformation() {
/**
* The action to take when the vault timeout is reached.
*/
@Serializable
enum class Action {
@SerialName("lock")
LOCK,
@SerialName("logOut")
LOGOUT,
}
/**
* The type of vault timeout.
*/
@Serializable
enum class Type {
@SerialName("never")
NEVER,
@SerialName("onAppRestart")
ON_APP_RESTART,
@SerialName("onSystemLock")
ON_SYSTEM_LOCK,
@SerialName("immediately")
IMMEDIATELY,
@SerialName("custom")
CUSTOM,
}
}
}

View File

@@ -15,6 +15,6 @@ sealed class ResendEmailResult {
*/
data class Error(
val message: String?,
val error: Throwable,
val error: Throwable?,
) : ResendEmailResult()
}

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of updating a user's kdf settings to minimums
*/
sealed class UpdateKdfMinimumsResult {
/**
* Active account was not found
*/
object ActiveAccountNotFound : UpdateKdfMinimumsResult()
/**
* There was an error updating user to minimum kdf settings.
*
* @param error the error.
*/
data class Error(
val error: Throwable?,
) : UpdateKdfMinimumsResult()
/**
* Updated user to minimum kdf settings successfully.
*/
object Success : UpdateKdfMinimumsResult()
}

View File

@@ -75,6 +75,7 @@ data class UserState(
val isUsingKeyConnector: Boolean,
val onboardingStatus: OnboardingStatus,
val firstTimeState: FirstTimeState,
val isExportable: Boolean,
) {
/**
* Indicates that the user does or does not have a means to manually unlock the vault.

View File

@@ -1,6 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
private const val DUO_HOST: String = "duo-callback"
@@ -16,21 +19,44 @@ private const val DUO_HOST: String = "duo-callback"
*/
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
val localData = data
return if (
action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST
) {
val code = localData.getQueryParameter("code")
val state = localData.getQueryParameter("state")
if (code != null && state != null) {
DuoCallbackTokenResult.Success(token = "$code|$state")
} else {
DuoCallbackTokenResult.MissingToken
}
return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) {
localData.getDuoCallbackTokenResult()
} else {
null
}
}
/**
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
*
* - `null`: Intent is not a Duo callback, or data is null.
*
* - [DuoCallbackTokenResult.MissingToken]: Intent is the Duo callback, but it's missing the code or
* state value.
*
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
*/
@OmitFromCoverage
fun AuthTabIntent.AuthResult.getDuoCallbackTokenResult(): DuoCallbackTokenResult =
when (this.resultCode) {
AuthTabIntent.RESULT_OK -> this.resultUri.getDuoCallbackTokenResult()
AuthTabIntent.RESULT_CANCELED -> DuoCallbackTokenResult.MissingToken
AuthTabIntent.RESULT_UNKNOWN_CODE -> DuoCallbackTokenResult.MissingToken
AuthTabIntent.RESULT_VERIFICATION_FAILED -> DuoCallbackTokenResult.MissingToken
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> DuoCallbackTokenResult.MissingToken
else -> DuoCallbackTokenResult.MissingToken
}
private fun Uri?.getDuoCallbackTokenResult(): DuoCallbackTokenResult {
val code = this?.getQueryParameter("code")
val state = this?.getQueryParameter("state")
return if (code != null && state != null) {
DuoCallbackTokenResult.Success(token = "$code|$state")
} else {
DuoCallbackTokenResult.MissingToken
}
}
/**
* Sealed class representing the result of Duo callback token extraction.
*/

View File

@@ -1,7 +1,10 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.parcelize.Parcelize
import java.net.URLEncoder
import java.security.MessageDigest
@@ -61,21 +64,40 @@ fun generateUriForSso(
fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
val localData = data
return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) {
val state = localData.getQueryParameter("state")
val code = localData.getQueryParameter("code")
if (code != null) {
SsoCallbackResult.Success(
state = state,
code = code,
)
} else {
SsoCallbackResult.MissingCode
}
localData.getSsoCallbackResult()
} else {
null
}
}
/**
* Retrieves an [SsoCallbackResult] from an [AuthTabIntent.AuthResult]. There are two possible
* cases.
*
* - [SsoCallbackResult.MissingCode]: The code is missing.
* - [SsoCallbackResult.Success]: The relevant data is present.
*/
@OmitFromCoverage
fun AuthTabIntent.AuthResult.getSsoCallbackResult(): SsoCallbackResult =
when (this.resultCode) {
AuthTabIntent.RESULT_OK -> this.resultUri.getSsoCallbackResult()
AuthTabIntent.RESULT_CANCELED -> SsoCallbackResult.MissingCode
AuthTabIntent.RESULT_UNKNOWN_CODE -> SsoCallbackResult.MissingCode
AuthTabIntent.RESULT_VERIFICATION_FAILED -> SsoCallbackResult.MissingCode
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> SsoCallbackResult.MissingCode
else -> SsoCallbackResult.MissingCode
}
private fun Uri?.getSsoCallbackResult(): SsoCallbackResult {
val state = this?.getQueryParameter("state")
val code = this?.getQueryParameter("code")
return if (code != null) {
SsoCallbackResult.Success(state = state, code = code)
} else {
SsoCallbackResult.MissingCode
}
}
/**
* Sealed class representing the result of an SSO callback data extraction.
*/

View File

@@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
@@ -12,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
@@ -32,6 +35,7 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
val updatedProfile = profile.copy(userDecryptionOptions = updatedUserDecryptionOptions)
val updatedAccount = account.copy(profile = updatedProfile)
@@ -54,6 +58,23 @@ fun UserStateJson.toUpdatedUserStateJson(
val userId = syncProfile.id
val account = this.accounts[userId] ?: return this
val profile = account.profile
val userDecryptionOptions = syncResponse
.userDecryption
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
}
?: profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = null)
val updatedProfile = profile
.copy(
avatarColorHex = syncProfile.avatarColor,
@@ -61,6 +82,7 @@ fun UserStateJson.toUpdatedUserStateJson(
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
@@ -90,6 +112,7 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)
@@ -103,6 +126,30 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
)
}
/**
* Updates the [UserStateJson] KDF settings to minimum requirements.
*/
fun UserStateJson.toUserStateJsonKdfUpdatedMinimums(): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val updatedProfile = profile
.copy(
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
kdfMemory = null,
kdfParallelism = null,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(activeUserId, updatedAccount)
},
)
}
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/
@@ -118,6 +165,7 @@ fun UserStateJson.toUserState(
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
getUserPolicies: (userId: String, policy: PolicyTypeJson) -> List<SyncResponseJson.Policy>,
): UserState =
UserState(
activeUserId = this.activeUserId,
@@ -157,6 +205,19 @@ fun UserStateJson.toUserState(
hasManageResetPasswordPermission.takeIf { trustedDevice != null }
val needsMasterPassword = decryptionOptions?.hasMasterPassword == false &&
(tdeUserNeedsMasterPassword ?: (keyConnectorOptions == null))
val hasPersonalOwnershipRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.PERSONAL_OWNERSHIP,
)
.any { it.isEnabled }
val hasPersonalVaultExportRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT,
)
.any { it.isEnabled }
UserState.Account(
userId = userId,
name = profile.name,
@@ -185,6 +246,8 @@ fun UserStateJson.toUserState(
// using the app prior to the release of the onboarding flow.
onboardingStatus = onboardingStatus ?: OnboardingStatus.COMPLETE,
firstTimeState = firstTimeState,
isExportable = !hasPersonalOwnershipRestrictedOrg &&
!hasPersonalVaultExportRestrictedOrg,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,

View File

@@ -2,6 +2,9 @@ package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -24,15 +27,36 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
localData != null &&
localData.host == WEB_AUTH_HOST
) {
localData
.getQueryParameter("data")
?.let { WebAuthResult.Success(token = it) }
?: WebAuthResult.Failure(message = localData.getQueryParameter("error"))
localData.getWebAuthResult()
} else {
null
}
}
/**
* Retrieves an [WebAuthResult] from an [AuthTabIntent.AuthResult]. There are two possible cases.
*
* - [WebAuthResult.Success]: The URI is the web auth key callback with correct data.
* - [WebAuthResult.Failure]: The URI is the web auth key callback with incorrect data or a failure
* has occurred.
*/
@OmitFromCoverage
fun AuthTabIntent.AuthResult.getWebAuthResult(): WebAuthResult =
when (this.resultCode) {
AuthTabIntent.RESULT_OK -> this.resultUri.getWebAuthResult()
AuthTabIntent.RESULT_CANCELED -> WebAuthResult.Failure(message = null)
AuthTabIntent.RESULT_UNKNOWN_CODE -> WebAuthResult.Failure(message = null)
AuthTabIntent.RESULT_VERIFICATION_FAILED -> WebAuthResult.Failure(message = null)
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> WebAuthResult.Failure(message = null)
else -> WebAuthResult.Failure(message = null)
}
private fun Uri?.getWebAuthResult(): WebAuthResult =
this
?.getQueryParameter("data")
?.let { WebAuthResult.Success(token = it) }
?: WebAuthResult.Failure(message = this?.getQueryParameter("error"))
/**
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
*/
@@ -59,7 +83,7 @@ fun generateUriForWebAuth(
"?data=$base64Data" +
"&parent=$parentParam" +
"&v=2"
return Uri.parse(url)
return url.toUri()
}
/**

View File

@@ -136,6 +136,11 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.ironfoxoss.ironfox.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"),
// [DEPRECATED ENTRY]
Browser(

View File

@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.util.buildFilledItemOrNull
import com.x8bit.bitwarden.data.autofill.util.buildUri
import timber.log.Timber
/**
@@ -83,6 +84,7 @@ class FilledDataBuilderImpl(
autofillCipher = autofillCipher,
autofillViews = autofillRequest.partition.views,
inlinePresentationSpec = getCipherInlinePresentationOrNull(),
packageName = autofillRequest.packageName,
)
}
}
@@ -96,7 +98,9 @@ class FilledDataBuilderImpl(
?.getOrLastOrNull(inlineSuggestionsAdded)
return FilledData(
filledPartitions = filledPartitions.take(n = MAX_FILLED_PARTITIONS_COUNT),
filledPartitions = filledPartitions
.filter { it.filledItems.isNotEmpty() }
.take(n = MAX_FILLED_PARTITIONS_COUNT),
ignoreAutofillIds = autofillRequest.ignoreAutofillIds,
originalPartition = autofillRequest.partition,
uri = autofillRequest.uri,
@@ -140,16 +144,21 @@ class FilledDataBuilderImpl(
autofillCipher: AutofillCipher.Login,
autofillViews: List<AutofillView.Login>,
inlinePresentationSpec: InlinePresentationSpec?,
packageName: String?,
): FilledPartition {
val filledItems = autofillViews
.mapNotNull { autofillView ->
val value = when (autofillView) {
is AutofillView.Login.Username -> autofillCipher.username
is AutofillView.Login.Password -> autofillCipher.password
if (autofillView.data.website == autofillCipher.website ||
buildUri(packageName.orEmpty(), "androidapp") == autofillCipher.website
) {
val value = when (autofillView) {
is AutofillView.Login.Username -> autofillCipher.username
is AutofillView.Login.Password -> autofillCipher.password
}
autofillView.buildFilledItemOrNull(value = value)
} else {
null
}
autofillView.buildFilledItemOrNull(
value = value,
)
}
return FilledPartition(

View File

@@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManagerImpl
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
@@ -24,6 +26,8 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -61,6 +65,22 @@ object AutofillModule {
fun providesBrowserAutofillEnabledManager(): BrowserThirdPartyAutofillEnabledManager =
BrowserThirdPartyAutofillEnabledManagerImpl()
@Singleton
@Provides
fun providesBrowserAutofillDialogManager(
autofillEnabledManager: AutofillEnabledManager,
browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
clock: Clock,
firstTimeActionManager: FirstTimeActionManager,
settingsDiskSource: SettingsDiskSource,
): BrowserAutofillDialogManager = BrowserAutofillDialogManagerImpl(
autofillEnabledManager = autofillEnabledManager,
browserThirdPartyAutofillEnabledManager = browserThirdPartyAutofillEnabledManager,
clock = clock,
firstTimeActionManager = firstTimeActionManager,
settingsDiskSource = settingsDiskSource,
)
@Singleton
@Provides
fun provideAutofillCompletionManager(

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.autofill.manager.browser
/**
* Manager to handle whether the Browser Autofill Dialog should be displayed.
*/
interface BrowserAutofillDialogManager {
/**
* Number of browsers installed that may need autofill enabled.
*/
val browserCount: Int
/**
* Indicates whether the dialog should be displayed to the user.
*/
val shouldShowDialog: Boolean
/**
* The dialog has been dismissed and we should delay displaying it again.
*/
fun delayDialog()
}

View File

@@ -0,0 +1,42 @@
package com.x8bit.bitwarden.data.autofill.manager.browser
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import java.time.Clock
/**
* We only show the dialog once per 24 hour period.
*/
private const val SHOW_DIALOG_DELAY_MS: Long = 24L * 60L * 60L * 1000L
/**
* The default implementation of the [BrowserAutofillDialogManager].
*/
internal class BrowserAutofillDialogManagerImpl(
private val autofillEnabledManager: AutofillEnabledManager,
private val browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
private val clock: Clock,
private val firstTimeActionManager: FirstTimeActionManager,
private val settingsDiskSource: SettingsDiskSource,
) : BrowserAutofillDialogManager {
override val browserCount: Int
get() = browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.availableCount
override val shouldShowDialog: Boolean
get() = autofillEnabledManager.isAutofillEnabled &&
browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.isAnyIsAvailableAndDisabled &&
!firstTimeActionManager
.currentOrDefaultUserFirstTimeState
.showSetupBrowserAutofillCard &&
settingsDiskSource.browserAutofillDialogReshowTime?.isBefore(clock.instant()) != false
override fun delayDialog() {
settingsDiskSource.browserAutofillDialogReshowTime =
clock.instant().plusMillis(SHOW_DIALOG_DELAY_MS)
}
}

View File

@@ -66,6 +66,7 @@ sealed class AutofillCipher {
override val subtitle: String,
val password: String,
val username: String,
val website: String,
) : AutofillCipher() {
override val iconRes: Int
@DrawableRes get() = BitwardenDrawable.ic_globe

View File

@@ -16,6 +16,7 @@ sealed class AutofillView {
* @param isFocused Whether the view is currently focused.
* @param textValue A text value that represents the input present in the field.
* @param hasPasswordTerms Indicates that the field includes password terms.
* @param website website associated with this view.
*/
data class Data(
val autofillId: AutofillId,
@@ -24,6 +25,7 @@ sealed class AutofillView {
val isFocused: Boolean,
val textValue: String?,
val hasPasswordTerms: Boolean,
val website: String?,
)
/**

View File

@@ -7,12 +7,12 @@ import android.view.autofill.AutofillId
*
* @param autofillViews The list of views we care about for autofilling.
* @param idPackage The package id for this view, if there is one.
* @param urlBarWebsites The website associated with the URL bar view.
* @param ignoreAutofillIds The list of [AutofillId]s that should be ignored in the fill response.
* @param website The website that is being displayed in the app, given there is one.
*/
data class ViewNodeTraversalData(
val autofillViews: List<AutofillView>,
val idPackage: String?,
val urlBarWebsites: List<String>,
val ignoreAutofillIds: List<AutofillId>,
val website: String?,
)

View File

@@ -6,7 +6,9 @@ package com.x8bit.bitwarden.data.autofill.model.browser
data class BrowserThirdPartyAutoFillData(
val isAvailable: Boolean,
val isThirdPartyEnabled: Boolean,
)
) {
val isAvailableButDisabled: Boolean = isAvailable && !isThirdPartyEnabled
}
/**
* The overall status for all relevant browsers.
@@ -15,4 +17,20 @@ data class BrowserThirdPartyAutofillStatus(
val braveStableStatusData: BrowserThirdPartyAutoFillData,
val chromeStableStatusData: BrowserThirdPartyAutoFillData,
val chromeBetaChannelStatusData: BrowserThirdPartyAutoFillData,
)
) {
/**
* The total number of available browsers.
*/
val availableCount: Int
get() = (if (braveStableStatusData.isAvailable) 1 else 0) +
(if (chromeStableStatusData.isAvailable) 1 else 0) +
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0)
/**
* Whether any of the available browsers have third party autofill disabled.
*/
val isAnyIsAvailableAndDisabled: Boolean
get() = braveStableStatusData.isAvailableButDisabled ||
chromeStableStatusData.isAvailableButDisabled ||
chromeBetaChannelStatusData.isAvailableButDisabled
}

View File

@@ -29,6 +29,20 @@ private val BLOCK_LISTED_URIS: List<String> = listOf(
"androidapp://com.oneplus.applocker",
)
/**
* A map of package ids and the known associated id entry for their url bar.
*/
private val URL_BARS: Map<String, String> = mapOf(
"com.microsoft.emmx" to "url_bar",
"com.microsoft.emmx.beta" to "url_bar",
"com.microsoft.emmx.canary" to "url_bar",
"com.microsoft.emmx.dev" to "url_bar",
"com.sec.android.app.sbrowser" to "location_bar_edit_text",
"com.sec.android.app.sbrowser.beta" to "location_bar_edit_text",
"com.opera.browser" to "url_bar",
"com.opera.browser.beta" to "url_bar",
)
/**
* The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data
* from the OS into domain models.
@@ -76,18 +90,24 @@ class AutofillParserImpl(
Timber.d("Parsing AssistStructure -- ${fillRequest?.id}")
// Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse()
val urlBarWebsite = traversalDataList
.flatMap { it.urlBarWebsites }
.firstOrNull()
?.takeIf { settingsRepository.isAutofillWebDomainCompatMode }
// Take only the autofill views from the node that currently has focus.
// Then remove all the fields that cannot be filled with data.
// We fallback to taking all the fillable views if nothing has focus.
val autofillViewsList = traversalDataList.map { it.autofillViews }
val autofillViews = autofillViewsList
val autofillViews = (autofillViewsList
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: autofillViewsList
.flatten()
.filter { it !is AutofillView.Unused }
.filter { it !is AutofillView.Unused })
.map { it.updateWebsiteIfNecessary(website = urlBarWebsite) }
// Find the focused view, or fallback to the first fillable item on the screen (so
// we at least have something to hook into)
@@ -95,16 +115,21 @@ class AutofillParserImpl(
.firstOrNull { it.data.isFocused }
?: autofillViews.firstOrNull()
if (focusedView == null) {
// The view is unfillable if there are no focused views.
return AutofillRequest.Unfillable
}
val packageName = traversalDataList.buildPackageNameOrNull(
assistStructure = assistStructure,
)
val uri = traversalDataList.buildUriOrNull(
val uri = focusedView.buildUriOrNull(
packageName = packageName,
)
val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS
if (focusedView == null || blockListedURIs.contains(uri)) {
// The view is unfillable if there are no focused views or the URI is block listed.
if (blockListedURIs.contains(uri)) {
// The view is unfillable if the URI is block listed.
return AutofillRequest.Unfillable
}
@@ -165,7 +190,7 @@ private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
.mapNotNull { windowNode ->
windowNode
.rootViewNode
?.traverse()
?.traverse(parentWebsite = null)
?.updateForMissingPasswordFields()
?.updateForMissingUsernameFields()
}
@@ -243,16 +268,25 @@ private fun ViewNodeTraversalData.copyAndMapAutofillViews(
* Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the
* data into [ViewNodeTraversalData].
*/
private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData {
private fun AssistStructure.ViewNode.traverse(
parentWebsite: String?,
): ViewNodeTraversalData {
// Set up mutable lists for collecting valid AutofillViews and ignorable view ids.
val mutableAutofillViewList: MutableList<AutofillView> = mutableListOf()
val mutableIgnoreAutofillIdList: MutableList<AutofillId> = mutableListOf()
var idPackage: String? = this.idPackage
var website: String? = this.website
// OS sometimes defaults node.idPackage to "android", which is not a valid
// package name so it is ignored to prevent auto-filling unknown applications.
var storedIdPackage: String? = this.idPackage?.takeUnless { it.isBlank() || it == "android" }
val storedUrlBarId = storedIdPackage?.let { URL_BARS[it] }
val storedUrlBarWebsites: MutableList<String> = this
.website
?.takeIf { _ -> storedUrlBarId != null && storedUrlBarId == this.idEntry }
?.let { mutableListOf(it) }
?: mutableListOf()
// Try converting this `ViewNode` into an `AutofillView`. If a valid instance is returned, add
// it to the list. Otherwise, ignore the `AutofillId` associated with this `ViewNode`.
toAutofillView()
toAutofillView(parentWebsite = parentWebsite)
?.run(mutableAutofillViewList::add)
?: autofillId?.run(mutableIgnoreAutofillIdList::add)
@@ -260,23 +294,18 @@ private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData {
for (i in 0 until childCount) {
// Extract the traversal data from each child view node and add it to the lists.
getChildAt(i)
.traverse()
.traverse(parentWebsite = website)
.let { viewNodeTraversalData ->
viewNodeTraversalData.autofillViews.forEach(mutableAutofillViewList::add)
viewNodeTraversalData.ignoreAutofillIds.forEach(mutableIgnoreAutofillIdList::add)
// Get the first non-null idPackage.
if (idPackage.isNullOrBlank() &&
// OS sometimes defaults node.idPackage to "android", which is not a valid
// package name so it is ignored to prevent auto-filling unknown applications.
viewNodeTraversalData.idPackage?.equals("android") == false
) {
idPackage = viewNodeTraversalData.idPackage
}
// Get the first non-null website.
if (website == null) {
website = viewNodeTraversalData.website
if (storedIdPackage == null) {
storedIdPackage = viewNodeTraversalData.idPackage
}
// Add all url bar websites. We will deal with this later if
// there is somehow more than one.
storedUrlBarWebsites.addAll(viewNodeTraversalData.urlBarWebsites)
}
}
@@ -284,8 +313,29 @@ private fun AssistStructure.ViewNode.traverse(): ViewNodeTraversalData {
// descendant's.
return ViewNodeTraversalData(
autofillViews = mutableAutofillViewList,
idPackage = idPackage,
idPackage = storedIdPackage,
urlBarWebsites = storedUrlBarWebsites,
ignoreAutofillIds = mutableIgnoreAutofillIdList,
website = website,
)
}
/**
* This updates the underlying [AutofillView.data] with the given [website] if it does not already
* have a website associated with it.
*/
private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillView {
val site = website ?: return this
if (this.data.website != null) return this
return when (this) {
is AutofillView.Card.Brand -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.CardholderName -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.ExpirationDate -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.ExpirationMonth -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.ExpirationYear -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.Number -> this.copy(data = this.data.copy(website = site))
is AutofillView.Card.SecurityCode -> this.copy(data = this.data.copy(website = site))
is AutofillView.Login.Password -> this.copy(data = this.data.copy(website = site))
is AutofillView.Login.Username -> this.copy(data = this.data.copy(website = site))
is AutofillView.Unused -> this.copy(data = this.data.copy(website = site))
}
}

View File

@@ -127,6 +127,7 @@ class AutofillCipherProviderImpl(
password = cipherView.login?.password.orEmpty(),
subtitle = cipherView.subtitle.orEmpty(),
username = cipherView.login?.username.orEmpty(),
website = uri,
)
}
}

View File

@@ -4,9 +4,15 @@ import android.view.View
import android.view.autofill.AutofillValue
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
/**
* The android app URI scheme. Example: androidapp://com.x8bit.bitwarden
*/
private const val ANDROID_APP_SCHEME: String = "androidapp"
/**
* Convert this [AutofillView] into a [FilledItem]. Return null if not possible.
*/
@@ -96,3 +102,17 @@ private fun AutofillView.buildListAutofillValueOrNull(
?.let { AutofillValue.forList(it) }
}
}
/**
* Try and build a URI. First, try building a website from the list of [ViewNodeTraversalData]. If
* that fails, try converting [packageName] into an Android app URI.
*/
fun AutofillView.buildUriOrNull(
packageName: String?,
): String? {
// Search list of ViewNodeTraversalData for a website URI.
this.data.website?.let { websiteUri -> return websiteUri }
// If the package name is available, build a URI out of that.
return packageName?.let { buildUri(domain = it, scheme = ANDROID_APP_SCHEME) }
}

View File

@@ -41,6 +41,7 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
password = login.password.orEmpty(),
subtitle = subtitle.orEmpty(),
username = login.username.orEmpty(),
website = uri,
),
)
}

View File

@@ -49,34 +49,30 @@ private val AssistStructure.ViewNode.isInputField: Boolean
* doesn't contain a valid autofillId, it isn't an a view setup for autofill, so we return null. If
* it doesn't have a supported hint and isn't an input field, we also return null.
*/
fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
this
.autofillId
// We only care about nodes with a valid `AutofillId`.
?.let { nonNullAutofillId ->
if (supportedAutofillHint != null || this.isInputField) {
val autofillOptions = this
.autofillOptions
.orEmpty()
.map { it.toString() }
val autofillViewData = AutofillView.Data(
autofillId = nonNullAutofillId,
autofillOptions = autofillOptions,
autofillType = this.autofillType,
isFocused = this.isFocused,
textValue = this.autofillValue?.extractTextValue(),
hasPasswordTerms = this.hasPasswordTerms(),
)
buildAutofillView(
autofillOptions = autofillOptions,
autofillViewData = autofillViewData,
autofillHint = supportedAutofillHint,
)
} else {
null
}
}
fun AssistStructure.ViewNode.toAutofillView(
parentWebsite: String?,
): AutofillView? {
val nonNullAutofillId = this.autofillId ?: return null
if (this.supportedAutofillHint == null && !this.isInputField) return null
val autofillOptions = this
.autofillOptions
.orEmpty()
.map { it.toString() }
val autofillViewData = AutofillView.Data(
autofillId = nonNullAutofillId,
autofillOptions = autofillOptions,
autofillType = this.autofillType,
isFocused = this.isFocused,
textValue = this.autofillValue?.extractTextValue(),
hasPasswordTerms = this.hasPasswordTerms(),
website = this.website ?: parentWebsite,
)
return buildAutofillView(
autofillOptions = autofillOptions,
autofillViewData = autofillViewData,
autofillHint = this.supportedAutofillHint,
)
}
/**
* The first supported autofill hint for this view node, or null if none are found.

View File

@@ -4,36 +4,6 @@ import android.app.assist.AssistStructure
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
/**
* The android app URI scheme. Example: androidapp://com.x8bit.bitwarden
*/
private const val ANDROID_APP_SCHEME: String = "androidapp"
/**
* Try and build a URI. First, try building a website from the list of [ViewNodeTraversalData]. If
* that fails, try converting [packageName] into an Android app URI.
*/
fun List<ViewNodeTraversalData>.buildUriOrNull(
packageName: String?,
): String? {
// Search list of ViewNodeTraversalData for a website URI.
this
.firstOrNull { it.website != null }
?.website
?.let { websiteUri ->
return websiteUri
}
// If the package name is available, build a URI out of that.
return packageName
?.let { nonNullPackageName ->
buildUri(
domain = nonNullPackageName,
scheme = ANDROID_APP_SCHEME,
)
}
}
/**
* Try and build a package name. First, try searching traversal data for package names. If that
* fails, try extracting a package name from [assistStructure].

View File

@@ -24,7 +24,6 @@ import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepositoryImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -93,13 +92,11 @@ object CredentialProviderModule {
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
privilegedAppRepository: PrivilegedAppRepository,
featureFlagManager: FeatureFlagManager,
): OriginManager =
OriginManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
privilegedAppRepository = privilegedAppRepository,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.credentials.manager
import android.util.Base64
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.exceptions.GetCredentialUnknownException
@@ -15,6 +16,7 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.Origin
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
@@ -24,7 +26,6 @@ import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.util.isActiveWithCopyablePassword
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult
@@ -41,7 +42,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2Cred
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidAttestationResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.fold
@@ -207,8 +207,6 @@ class BitwardenCredentialManagerImpl(
.beginGetPublicKeyCredentialOptions
.toPublicKeyCredentialEntries(
userId = getCredentialsRequest.userId,
cipherListViews = cipherListViews
.filter { it.isActiveWithFido2Credentials },
)
.onFailure { Timber.e(it, "Failed to get FIDO 2 credential entries.") }
@@ -225,65 +223,72 @@ class BitwardenCredentialManagerImpl(
private suspend fun List<BeginGetPublicKeyCredentialOption>.toPublicKeyCredentialEntries(
userId: String,
cipherListViews: List<CipherListView>,
): Result<List<CredentialEntry>> {
if (this.isEmpty()) return emptyList<CredentialEntry>().asSuccess()
val assertionOptions = this
.mapNotNull { getPasskeyAssertionOptionsOrNull(it.requestJson) }
.ifEmpty {
return GetCredentialUnknownException(
"Passkey assertion options required.",
)
.asFailure()
}
val relyingPartyIds = this
.mapNotNull { getPasskeyAssertionOptionsOrNull(it.requestJson)?.relyingPartyId }
.distinct()
val relyingPartyIds = assertionOptions
.mapNotNull { it.relyingPartyId }
.toSet()
.ifEmpty {
return GetCredentialUnknownException("Relying party id required.").asFailure()
}
val cipherViews = cipherListViews
.filter { cipherListView ->
cipherListView.login
?.fido2Credentials
val allowedCredentials = assertionOptions
.flatMap { option ->
option
.allowCredentials
?.map { it.id }
.orEmpty()
.any { credential -> credential.rpId in relyingPartyIds }
}
.mapNotNull { cipherListView ->
when (val result = vaultRepository.getCipher(cipherListView.id.orEmpty())) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found while building public key credential entries.")
null
}
is GetCipherResult.Failure -> {
Timber.e(
result.error,
"Failed to decrypt cipher while building credential entries.",
)
null
}
is GetCipherResult.Success -> result.cipherView
}
val discoveredCredentials = relyingPartyIds
.flatMap { relyingPartyId ->
vaultSdkSource
.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
.fold(
onSuccess = { it },
onFailure = {
Timber.e(it, "Failed to discover credentials.")
emptyList()
},
)
}
.toTypedArray()
.ifEmpty { return emptyList<CredentialEntry>().asSuccess() }
.filterAllowedCredentialsIfNecessary(allowedCredentials)
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
return credentialEntryBuilder
.buildPublicKeyCredentialEntries(
userId = userId,
cipherViews = cipherViews,
)
.fold(
onSuccess = { fido2AutofillViews ->
credentialEntryBuilder
.buildPublicKeyCredentialEntries(
userId = userId,
fido2CredentialAutofillViews = fido2AutofillViews,
beginGetPublicKeyCredentialOptions = this,
isUserVerified = isUserVerified,
)
.asSuccess()
},
onFailure = {
GetCredentialUnknownException("Error decrypting credentials.").asFailure()
},
fido2CredentialAutofillViews = discoveredCredentials,
beginGetPublicKeyCredentialOptions = this,
isUserVerified = isUserVerified,
)
.asSuccess()
}
private fun List<Fido2CredentialAutofillView>.filterAllowedCredentialsIfNecessary(
allowedCredentialIds: List<String>,
): List<Fido2CredentialAutofillView> = if (allowedCredentialIds.isEmpty()) {
this
} else {
this.filter {
Base64
.encodeToString(
it.credentialId,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
) in allowedCredentialIds
}
}
private suspend fun registerFido2CredentialForUnprivilegedApp(
@@ -318,6 +323,7 @@ class BitwardenCredentialManagerImpl(
createPublicKeyCredentialRequest = createPublicKeyCredentialRequest,
selectedCipherView = selectedCipherView,
clientData = clientData,
callingPackageName = callingAppInfo.packageName,
)
}
@@ -342,6 +348,7 @@ class BitwardenCredentialManagerImpl(
createPublicKeyCredentialRequest = createPublicKeyCredentialRequest,
selectedCipherView = selectedCipherView,
clientData = clientData,
callingPackageName = callingAppInfo.packageName,
)
}
@@ -351,6 +358,7 @@ class BitwardenCredentialManagerImpl(
createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest,
selectedCipherView: CipherView,
clientData: ClientData,
callingPackageName: String,
): Fido2RegisterCredentialResult = vaultSdkSource
.registerFido2Credential(
request = RegisterFido2CredentialRequest(
@@ -365,7 +373,9 @@ class BitwardenCredentialManagerImpl(
),
fido2CredentialStore = this,
)
.map { it.toAndroidAttestationResponse() }
.map {
it.toAndroidAttestationResponse(callingPackageName = callingPackageName)
}
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2RegisterCredentialResult.Success(it) },

View File

@@ -1,13 +1,11 @@
package com.x8bit.bitwarden.data.credentials.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber
@@ -23,7 +21,6 @@ class OriginManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val privilegedAppRepository: PrivilegedAppRepository,
private val featureFlagManager: FeatureFlagManager,
) : OriginManager {
override suspend fun validateOrigin(
@@ -70,10 +67,7 @@ class OriginManagerImpl(
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
.takeUnless {
it is ValidateOriginResult.Error.PrivilegedAppNotAllowed &&
featureFlagManager.getFeatureFlag(FlagKey.UserManagedPrivilegedApps)
}
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo)
private suspend fun validatePrivilegedAppSignatureWithGoogleList(

View File

@@ -20,7 +20,7 @@ data class Fido2AttestationResponse(
@SerialName("response")
val response: RegistrationResponse,
@SerialName("clientExtensionResults")
val clientExtensionResults: ClientExtensionResults?,
val clientExtensionResults: ClientExtensionResults,
@SerialName("authenticatorAttachment")
val authenticatorAttachment: String?,
) {
@@ -50,7 +50,7 @@ data class Fido2AttestationResponse(
@Serializable
data class ClientExtensionResults(
@SerialName("credProps")
val credentialProperties: CredentialProperties,
val credentialProperties: CredentialProperties? = null,
) {
/**
* Represents properties for newly created credential.

View File

@@ -105,6 +105,16 @@ interface SettingsDiskSource {
*/
val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
/**
* The time at which the browser autofill dialog is allowed to be shown to the user again.
*/
var browserAutofillDialogReshowTime: Instant?
/**
* The current status of whether the web domain compatibility mode is enabled.
*/
var isAutofillWebDomainCompatMode: Boolean?
/**
* Clears all the settings data for the given user.
*/
@@ -281,6 +291,23 @@ interface SettingsDiskSource {
*/
fun getUserHasSignedInPreviously(userId: String): Boolean
/**
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowBrowserAutofillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable the browser autofill integration in onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?)
/**
* Emits updates that track [getShowAutoFillSettingBadge] for the given [userId].
*/
fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
@@ -332,21 +359,21 @@ interface SettingsDiskSource {
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether or not the given [userId] has registered for export via the credential exchange
* Gets whether or not the application has registered for export via the credential exchange
* protocol.
*/
fun getVaultRegisteredForExport(userId: String): Boolean?
fun getAppRegisteredForExport(): Boolean?
/**
* Stores the given value for whether or not the given [userId] has registered for export via
* Stores the given value for whether or not the application has registered for export via
* the credential exchange protocol.
*/
fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?)
fun storeAppRegisteredForExport(isRegistered: Boolean?)
/**
* Emits updates that track [getVaultRegisteredForExport] for the given [userId].
* Emits updates that track [getAppRegisteredForExport].
*/
fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?>
fun getAppRegisteredForExportFlow(userId: String): Flow<Boolean?>
/**
* Gets the number of qualifying add cipher actions for the device.

View File

@@ -37,6 +37,7 @@ private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "clearClipboard"
private const val INITIAL_AUTOFILL_DIALOG_SHOWN = "addSitePromptShown"
private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedInOrCreatedAccount"
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
private const val SHOW_BROWSER_AUTOFILL_SETTING_BADGE = "showBrowserAutofillSettingBadge"
private const val SHOW_UNLOCK_SETTING_BADGE = "showUnlockSettingBadge"
private const val SHOW_IMPORT_LOGINS_SETTING_BADGE = "showImportLoginsSettingBadge"
private const val IS_VAULT_REGISTERED_FOR_EXPORT = "isVaultRegisteredForExport"
@@ -48,6 +49,8 @@ private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMa
private const val RESUME_SCREEN = "resumeScreen"
private const val FLIGHT_RECORDER_KEY = "flightRecorderData"
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
private const val AUTOFILL_WEB_DOMAIN_COMPATIBILITY = "autofillWebDomainCompatibility"
/**
* Primary implementation of [SettingsDiskSource].
@@ -72,6 +75,9 @@ class SettingsDiskSourceImpl(
private val mutablePullToRefreshEnabledFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableShowBrowserAutofillSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableShowAutoFillSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@@ -95,8 +101,7 @@ class SettingsDiskSourceImpl(
private val mutableScreenCaptureAllowedFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableVaultRegisteredForExportFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableVaultRegisteredForExportFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -224,6 +229,18 @@ class SettingsDiskSourceImpl(
override val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) }
override var browserAutofillDialogReshowTime: Instant?
get() = getLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME)?.let { Instant.ofEpochMilli(it) }
set(value) {
putLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME, value = value?.toEpochMilli())
}
override var isAutofillWebDomainCompatMode: Boolean?
get() = getBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY)
set(value) {
putBoolean(key = AUTOFILL_WEB_DOMAIN_COMPATIBILITY, value = value)
}
override fun clearData(userId: String) {
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)
storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null)
@@ -236,7 +253,6 @@ class SettingsDiskSourceImpl(
storeLastSyncTime(userId = userId, lastSyncTime = null)
storeClearClipboardFrequencySeconds(userId = userId, frequency = null)
removeWithPrefix(prefix = ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY.appendIdentifier(userId))
storeVaultRegisteredForExport(userId = userId, isRegistered = null)
storeAppResumeScreen(userId = userId, screenData = null)
// The following are intentionally not cleared so they can be
@@ -431,6 +447,21 @@ class SettingsDiskSourceImpl(
key = HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY.appendIdentifier(userId),
) == true
override fun getShowBrowserAutofillSettingBadge(userId: String): Boolean? =
getBoolean(key = SHOW_BROWSER_AUTOFILL_SETTING_BADGE.appendIdentifier(userId))
override fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?) {
putBoolean(
key = SHOW_BROWSER_AUTOFILL_SETTING_BADGE.appendIdentifier(userId),
value = showBadge,
)
getMutableShowBrowserAutofillSettingBadgeFlow(userId).tryEmit(showBadge)
}
override fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?> =
getMutableShowBrowserAutofillSettingBadgeFlow(userId = userId)
.onSubscription { emit(getShowBrowserAutofillSettingBadge(userId)) }
override fun getShowAutoFillSettingBadge(userId: String): Boolean? =
getBoolean(
key = SHOW_AUTOFILL_SETTING_BADGE.appendIdentifier(userId),
@@ -483,17 +514,17 @@ class SettingsDiskSourceImpl(
getMutableShowImportLoginsSettingBadgeFlow(userId)
.onSubscription { emit(getShowImportLoginsSettingBadge(userId)) }
override fun getVaultRegisteredForExport(userId: String): Boolean? =
getBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId))
override fun getAppRegisteredForExport(): Boolean? =
getBoolean(IS_VAULT_REGISTERED_FOR_EXPORT)
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?) {
putBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId), isRegistered)
getMutableVaultRegisteredForExportFlow(userId).tryEmit(isRegistered)
override fun storeAppRegisteredForExport(isRegistered: Boolean?) {
putBoolean(IS_VAULT_REGISTERED_FOR_EXPORT, isRegistered)
mutableVaultRegisteredForExportFlow.tryEmit(isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?> =
getMutableVaultRegisteredForExportFlow(userId)
.onSubscription { emit(getVaultRegisteredForExport(userId)) }
override fun getAppRegisteredForExportFlow(userId: String): Flow<Boolean?> =
mutableVaultRegisteredForExportFlow
.onSubscription { emit(getAppRegisteredForExport()) }
override fun getAddCipherActionCount(): Int? = getInt(
key = ADD_ACTION_COUNT,
@@ -598,6 +629,13 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowBrowserAutofillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableShowBrowserAutofillSettingBadgeFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowAutoFillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {
@@ -616,12 +654,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableVaultRegisteredForExportFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableVaultRegisteredForExportFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
/**
* Migrates the user-scoped screen capture state to an app-wide state.
*

View File

@@ -0,0 +1,6 @@
package com.x8bit.bitwarden.data.platform.error
/**
* An exception indicating that the security stamps for the current user do not match.
*/
class SecurityStampMismatchException : IllegalStateException("Security stamps do not match!")

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
/**
* Manager for registering for Credential Exchange Protocol export.
*/
interface CredentialExchangeRegistryManager {
/**
* Registers the application for Credential Exchange Protocol export.
*/
suspend fun register(): RegisterExportResult
/**
* Unregisters the application for Credential Exchange Protocol export.
*/
suspend fun unregister(): UnregisterExportResult
}

View File

@@ -0,0 +1,68 @@
package com.x8bit.bitwarden.data.platform.manager
import androidx.credentials.providerevents.transfer.CredentialTypes
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.model.RegistrationRequest
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import timber.log.Timber
/**
* Default implementation of [CredentialExchangeRegistryManager].
*/
class CredentialExchangeRegistryManagerImpl(
private val credentialExchangeRegistry: CredentialExchangeRegistry,
private val settingsDiskSource: SettingsDiskSource,
) : CredentialExchangeRegistryManager {
override suspend fun register(): RegisterExportResult = credentialExchangeRegistry
.register(
registrationRequest = RegistrationRequest(
appNameRes = R.string.app_name,
credentialTypes = setOf(
CredentialTypes.CREDENTIAL_TYPE_BASIC_AUTH,
CredentialTypes.CREDENTIAL_TYPE_PUBLIC_KEY,
CredentialTypes.CREDENTIAL_TYPE_ADDRESS,
CredentialTypes.CREDENTIAL_TYPE_API_KEY,
CredentialTypes.CREDENTIAL_TYPE_CREDIT_CARD,
CredentialTypes.CREDENTIAL_TYPE_CUSTOM_FIELDS,
CredentialTypes.CREDENTIAL_TYPE_DRIVERS_LICENSE,
CredentialTypes.CREDENTIAL_TYPE_IDENTITY_DOCUMENT,
CredentialTypes.CREDENTIAL_TYPE_NOTE,
CredentialTypes.CREDENTIAL_TYPE_PASSPORT,
CredentialTypes.CREDENTIAL_TYPE_PERSON_NAME,
CredentialTypes.CREDENTIAL_TYPE_SSH_KEY,
CredentialTypes.CREDENTIAL_TYPE_TOTP,
CredentialTypes.CREDENTIAL_TYPE_WIFI,
),
iconResId = BitwardenDrawable.logo_bitwarden_icon,
),
)
.fold(
onSuccess = {
Timber.d("Successfully registered for CXP export")
settingsDiskSource.storeAppRegisteredForExport(isRegistered = true)
RegisterExportResult.Success
},
onFailure = {
Timber.e(it, "Failed to register for CXP export")
RegisterExportResult.Failure(it)
},
)
override suspend fun unregister(): UnregisterExportResult = credentialExchangeRegistry
.unregister()
.fold(
onSuccess = {
Timber.d("Successfully unregistered for CXP export")
settingsDiskSource.storeAppRegisteredForExport(isRegistered = false)
UnregisterExportResult.Success
},
onFailure = {
Timber.e(it, "Failed to unregister for CXP export")
UnregisterExportResult.Failure(it)
},
)
}

View File

@@ -63,6 +63,12 @@ interface FirstTimeActionManager {
*/
fun storeShowUnlockSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether or not the active user has signalled they want to
* enable the browser autofill integration later, during onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether or not the active user has signalled they want to
* enable autofill later, during onboarding.

View File

@@ -4,6 +4,7 @@ import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@@ -25,12 +26,14 @@ import javax.inject.Inject
/**
* Implementation of [FirstTimeActionManager]
*/
@Suppress("TooManyFunctions")
class FirstTimeActionManagerImpl @Inject constructor(
dispatcherManager: DispatcherManager,
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val autofillEnabledManager: AutofillEnabledManager,
private val thirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
) : FirstTimeActionManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
@@ -78,11 +81,12 @@ class FirstTimeActionManagerImpl @Inject constructor(
.activeUserIdChangesFlow
.filterNotNull()
.flatMapLatest {
// Can be expanded to support multiple autofill settings
getShowAutofillSettingBadgeFlowInternal(userId = it)
.map { showAutofillBadge ->
listOfNotNull(showAutofillBadge)
}
combine(
getShowAutofillSettingBadgeFlowInternal(userId = it),
getShowBrowserAutofillSettingBadgeFlowInternal(userId = it),
) { showAutofillBadge, showBrowserAutofillBadge ->
listOf(showAutofillBadge, showBrowserAutofillBadge)
}
.map { list ->
list.count { showBadge -> showBadge }
}
@@ -124,6 +128,7 @@ class FirstTimeActionManagerImpl @Inject constructor(
settingsDiskSource.getShowUnlockSettingBadgeFlow(userId = activeUserId),
getShowAutofillSettingBadgeFlowInternal(userId = activeUserId),
getShowImportLoginsSettingBadgeFlowInternal(userId = activeUserId),
getShowBrowserAutofillSettingBadgeFlowInternal(userId = activeUserId),
),
) {
FirstTimeState(
@@ -131,19 +136,11 @@ class FirstTimeActionManagerImpl @Inject constructor(
showSetupUnlockCard = it[1],
showSetupAutofillCard = it[2],
showImportLoginsCardInSettings = it[3],
showSetupBrowserAutofillCard = it[4],
)
}
}
.onStart {
emit(
FirstTimeState(
showImportLoginsCard = null,
showSetupUnlockCard = null,
showSetupAutofillCard = null,
showImportLoginsCardInSettings = null,
),
)
}
.onStart { emit(currentOrDefaultUserFirstTimeState) }
.distinctUntilChanged()
override val shouldShowAddLoginCoachMarkFlow: Flow<Boolean>
@@ -176,14 +173,11 @@ class FirstTimeActionManagerImpl @Inject constructor(
showSetupAutofillCard = getShowAutofillSettingBadgeInternal(it),
showImportLoginsCardInSettings = settingsDiskSource
.getShowImportLoginsSettingBadge(it),
showSetupBrowserAutofillCard = settingsDiskSource
.getShowBrowserAutofillSettingBadge(it),
)
}
?: FirstTimeState(
showImportLoginsCard = null,
showSetupUnlockCard = null,
showSetupAutofillCard = null,
showImportLoginsCardInSettings = null,
)
?: FirstTimeState()
override fun storeShowUnlockSettingBadge(showBadge: Boolean) {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
@@ -193,6 +187,14 @@ class FirstTimeActionManagerImpl @Inject constructor(
)
}
override fun storeShowBrowserAutofillSettingBadge(showBadge: Boolean) {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storeShowBrowserAutofillSettingBadge(
userId = activeUserId,
showBadge = showBadge,
)
}
override fun storeShowAutoFillSettingBadge(showBadge: Boolean) {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storeShowAutoFillSettingBadge(
@@ -257,6 +259,19 @@ class FirstTimeActionManagerImpl @Inject constructor(
}
}
/**
* Internal implementation to get a flow of the showBrowserAutofill value which takes
* into account if autofill and if browser autofill is already enabled.
*/
private fun getShowBrowserAutofillSettingBadgeFlowInternal(userId: String): Flow<Boolean> =
combine(
settingsDiskSource.getShowBrowserAutofillSettingBadgeFlow(userId = userId),
autofillEnabledManager.isAutofillEnabledStateFlow,
thirdPartyAutofillEnabledManager.browserThirdPartyAutofillStatusFlow,
) { showBadge, autofillEnabled, status ->
showBadge ?: false && autofillEnabled && status.isAnyIsAvailableAndDisabled
}
/**
* Internal implementation to get a flow of the showAutofill value which takes
* into account if autofill is already enabled globally.

View File

@@ -17,4 +17,12 @@ interface PolicyManager {
* Get all the policies of the given [type] that are enabled and applicable to the user.
*/
fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy>
/**
* Get all the policies of the given [type] that are enabled and applicable to the [userId].
*/
fun getUserPolicies(
userId: String,
type: PolicyTypeJson,
): List<SyncResponseJson.Policy>
}

View File

@@ -54,6 +54,18 @@ class PolicyManagerImpl(
}
?: emptyList()
override fun getUserPolicies(
userId: String,
type: PolicyTypeJson,
): List<SyncResponseJson.Policy> =
this
.filterPolicies(
userId = userId,
type = type,
policies = authDiskSource.getPolicies(userId = userId),
)
.orEmpty()
/**
* A helper method to filter policies.
*/

View File

@@ -15,9 +15,9 @@ import kotlinx.coroutines.flow.Flow
*/
interface PushManager {
/**
* Flow that represents requests intended for full syncs.
* Flow that represents requests intended for full syncs for the user ID provided.
*/
val fullSyncFlow: Flow<Unit>
val fullSyncFlow: Flow<String>
/**
* Flow that represents requests intended to log a user out.
@@ -52,7 +52,7 @@ interface PushManager {
/**
* Flow that represents requests intended to trigger syncing organization keys.
*/
val syncOrgKeysFlow: Flow<Unit>
val syncOrgKeysFlow: Flow<String>
/**
* Flow that represents requests intended to trigger a sync send delete.

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.manager.DispatcherManager
@@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.model.NotificationPayload
import com.x8bit.bitwarden.data.platform.manager.model.NotificationType
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.PushNotificationLogOutReason
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
@@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
@@ -43,18 +46,20 @@ private val PUSH_TOKEN_UPDATE_DELAY: Duration = 7.days
/**
* Primary implementation of [PushManager].
*/
@Suppress("LongParameterList")
class PushManagerImpl @Inject constructor(
private val authDiskSource: AuthDiskSource,
private val pushDiskSource: PushDiskSource,
private val pushService: PushService,
private val clock: Clock,
private val json: Json,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
) : PushManager {
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
@@ -66,13 +71,13 @@ class PushManagerImpl @Inject constructor(
bufferedMutableSharedFlow<SyncFolderDeleteData>()
private val mutableSyncFolderUpsertSharedFlow =
bufferedMutableSharedFlow<SyncFolderUpsertData>()
private val mutableSyncOrgKeysSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableSyncOrgKeysSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableSyncSendDeleteSharedFlow =
bufferedMutableSharedFlow<SyncSendDeleteData>()
private val mutableSyncSendUpsertSharedFlow =
bufferedMutableSharedFlow<SyncSendUpsertData>()
override val fullSyncFlow: SharedFlow<Unit>
override val fullSyncFlow: SharedFlow<String>
get() = mutableFullSyncSharedFlow.asSharedFlow()
override val logoutFlow: SharedFlow<NotificationLogoutData>
@@ -93,7 +98,7 @@ class PushManagerImpl @Inject constructor(
override val syncFolderUpsertFlow: SharedFlow<SyncFolderUpsertData>
get() = mutableSyncFolderUpsertSharedFlow.asSharedFlow()
override val syncOrgKeysFlow: SharedFlow<Unit>
override val syncOrgKeysFlow: SharedFlow<String>
get() = mutableSyncOrgKeysSharedFlow.asSharedFlow()
override val syncSendDeleteFlow: SharedFlow<SyncSendDeleteData>
@@ -129,8 +134,8 @@ class PushManagerImpl @Inject constructor(
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun onMessageReceived(notification: BitwardenNotification) {
if (authDiskSource.uniqueAppId == notification.contextId) return
val userId = activeUserId ?: return
Timber.d("Push Notification Received: ${notification.notificationType}")
when (val type = notification.notificationType) {
NotificationType.AUTH_REQUEST,
@@ -156,8 +161,15 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.UserNotification>(
string = notification.payload,
)
.userId
?.let { mutableLogoutSharedFlow.tryEmit(NotificationLogoutData(it)) }
.takeUnless {
featureFlagManager.getFeatureFlag(FlagKey.NoLogoutOnKdfChange) &&
it.pushNotificationLogOutReason ==
PushNotificationLogOutReason.KDF_CHANGE
}
?.userId
?.let {
mutableLogoutSharedFlow.tryEmit(NotificationLogoutData(userId = it))
}
}
NotificationType.SYNC_CIPHER_CREATE,
@@ -189,16 +201,24 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncCipherNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.cipherId
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(SyncCipherDeleteData(it)) }
.takeIf { it.userId != null && it.cipherId != null }
?.let {
SyncCipherDeleteData(
userId = requireNotNull(it.userId),
cipherId = requireNotNull(it.cipherId),
)
}
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_CIPHERS,
NotificationType.SYNC_SETTINGS,
NotificationType.SYNC_VAULT,
-> {
mutableFullSyncSharedFlow.tryEmit(Unit)
json
.decodeFromString<NotificationPayload.SyncNotification>(notification.payload)
.userId
?.let { mutableFullSyncSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_FOLDER_CREATE,
@@ -226,15 +246,24 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncFolderNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.folderId
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(SyncFolderDeleteData(it)) }
.takeIf { it.userId != null && it.folderId != null }
?.let {
SyncFolderDeleteData(
userId = requireNotNull(it.userId),
folderId = requireNotNull(it.folderId),
)
}
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_ORG_KEYS -> {
if (isLoggedIn(userId)) {
mutableSyncOrgKeysSharedFlow.tryEmit(Unit)
}
json
.decodeFromString<NotificationPayload.SynchronizeOrganizationKeysNotifications>(
string = notification.payload,
)
.userId
.takeIf { authDiskSource.userState?.accounts.orEmpty().containsKey(it) }
?.let { mutableSyncOrgKeysSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_SEND_CREATE,
@@ -262,9 +291,14 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncSendNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.sendId
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(SyncSendDeleteData(it)) }
.takeIf { it.userId != null && it.sendId != null }
?.let {
SyncSendDeleteData(
userId = requireNotNull(it.userId),
sendId = requireNotNull(it.sendId),
)
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
}
}
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
@@ -13,14 +14,18 @@ class SdkClientManagerImpl(
sdkRepoFactory: SdkRepositoryFactory,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
Client(settings = null).apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
Client(
tokenProvider = sdkRepoFactory.getClientManagedTokens(userId = userId),
settings = null,
)
.apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
}
}
}
}
},
) : SdkClientManager {
private val userIdToClientMap = mutableMapOf<String?, Client>()

View File

@@ -7,8 +7,11 @@ import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.manager.realtime.RealtimeManagerImpl
import com.bitwarden.core.data.manager.toast.ToastManager
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.DispatcherManager
import com.bitwarden.data.manager.DispatcherManagerImpl
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.EventService
@@ -18,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -32,6 +36,8 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.CertificateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DebugMenuFeatureFlagManagerImpl
@@ -41,8 +47,6 @@ import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.LogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -242,10 +246,6 @@ object PlatformManagerModule {
)
}
@Provides
@Singleton
fun provideNativeLibraryManager(): NativeLibraryManager = NativeLibraryManagerImpl()
@Provides
@Singleton
fun provideSdkClientManager(
@@ -302,6 +302,7 @@ object PlatformManagerModule {
dispatcherManager: DispatcherManager,
clock: Clock,
json: Json,
featureFlagManager: FeatureFlagManager,
): PushManager = PushManagerImpl(
authDiskSource = authDiskSource,
pushDiskSource = pushDiskSource,
@@ -309,6 +310,7 @@ object PlatformManagerModule {
dispatcherManager = dispatcherManager,
clock = clock,
json = json,
featureFlagManager = featureFlagManager,
)
@Provides
@@ -355,12 +357,14 @@ object PlatformManagerModule {
vaultDiskSource: VaultDiskSource,
dispatcherManager: DispatcherManager,
autofillEnabledManager: AutofillEnabledManager,
thirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
): FirstTimeActionManager = FirstTimeActionManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
vaultDiskSource = vaultDiskSource,
dispatcherManager = dispatcherManager,
autofillEnabledManager = autofillEnabledManager,
thirdPartyAutofillEnabledManager = thirdPartyAutofillEnabledManager,
)
@Provides
@@ -391,8 +395,10 @@ object PlatformManagerModule {
@Singleton
fun provideSdkRepositoryFactory(
vaultDiskSource: VaultDiskSource,
bitwardenServiceClient: BitwardenServiceClient,
): SdkRepositoryFactory = SdkRepositoryFactoryImpl(
vaultDiskSource = vaultDiskSource,
bitwardenServiceClient = bitwardenServiceClient,
)
@Provides
@@ -422,4 +428,22 @@ object PlatformManagerModule {
clock = clock,
)
}
@Provides
@Singleton
fun provideCredentialExchangeRegistry(
application: Application,
): CredentialExchangeRegistry = credentialExchangeRegistry(
application = application,
)
@Provides
@Singleton
fun provideCredentialExchangeRegistryManager(
credentialExchangeRegistry: CredentialExchangeRegistry,
settingsDiskSource: SettingsDiskSource,
): CredentialExchangeRegistryManager = CredentialExchangeRegistryManagerImpl(
credentialExchangeRegistry = credentialExchangeRegistry,
settingsDiskSource = settingsDiskSource,
)
}

View File

@@ -8,6 +8,7 @@ data class FirstTimeState(
val showImportLoginsCardInSettings: Boolean,
val showSetupUnlockCard: Boolean,
val showSetupAutofillCard: Boolean,
val showSetupBrowserAutofillCard: Boolean,
) {
/**
* Constructs a [FirstTimeState] accepting nullable values. If a value is null, the default
@@ -18,10 +19,12 @@ data class FirstTimeState(
showSetupUnlockCard: Boolean? = null,
showSetupAutofillCard: Boolean? = null,
showImportLoginsCardInSettings: Boolean? = null,
showSetupBrowserAutofillCard: Boolean? = null,
) : this(
showImportLoginsCard = showImportLoginsCard ?: true,
showSetupUnlockCard = showSetupUnlockCard ?: false,
showSetupAutofillCard = showSetupAutofillCard ?: false,
showImportLoginsCardInSettings = showImportLoginsCardInSettings ?: false,
showSetupBrowserAutofillCard = showSetupBrowserAutofillCard ?: false,
)
}

View File

@@ -50,9 +50,15 @@ sealed class NotificationPayload {
*/
@Serializable
data class UserNotification(
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("UserId", "userId")
override val userId: String?,
@Contextual
@JsonNames("Date", "date") val date: ZonedDateTime?,
@JsonNames("Date", "date")
val date: ZonedDateTime?,
@JsonNames("PushNotificationLogOutReason", "pushNotificationLogOutReason")
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
) : NotificationPayload()
/**
@@ -74,4 +80,21 @@ sealed class NotificationPayload {
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
/**
* A notification payload for resynchronizing organization keys.
*/
@Serializable
data class SynchronizeOrganizationKeysNotifications(
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
/**
* A notification payload for syncing a users vault.
*/
@Serializable
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
}

View File

@@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.platform.manager.model
import kotlinx.serialization.SerialName
/**
* Enumerated values to represent the possible reasons for a log out push notification
*/
enum class PushNotificationLogOutReason {
@SerialName("0")
KDF_CHANGE,
}

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the result of registering for export.
*/
sealed class RegisterExportResult {
/**
* Registration was successful.
*/
data object Success : RegisterExportResult()
/**
* Registration failed.
*/
data class Failure(val throwable: Throwable?) : RegisterExportResult()
}

View File

@@ -2,7 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import androidx.credentials.CredentialManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.ui.platform.manager.share.model.ShareData
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
@@ -30,7 +31,7 @@ sealed class SpecialCircumstance : Parcelable {
*/
@Parcelize
data class ShareNewSend(
val data: IntentManager.ShareData,
val data: ShareData,
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
@@ -133,6 +134,14 @@ sealed class SpecialCircumstance : Parcelable {
@Parcelize
data object VerificationCodeShortcut : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.
*/
@Parcelize
data class CredentialExchangeExport(
val data: ImportCredentialsRequestData,
) : SpecialCircumstance()
/**
* A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be
* cleared after a successful login.

View File

@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync cipher delete operations.
*
* @property cipherId The cipher ID.
*/
data class SyncCipherDeleteData(
val userId: String,
val cipherId: String,
)

View File

@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync folder delete operations.
*
* @property folderId The folder ID.
*/
data class SyncFolderDeleteData(
val userId: String,
val folderId: String,
)

View File

@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync send delete operations.
*
* @property sendId The send ID.
*/
data class SyncSendDeleteData(
val userId: String,
val sendId: String,
)

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the result of unregistering for Credential Exchange Protocol export.
*/
sealed class UnregisterExportResult {
/**
* Represents a successful unregistering for Credential Exchange Protocol export.
*/
data object Success : UnregisterExportResult()
/**
* Represents a failure to unregister for Credential Exchange Protocol export.
*/
data class Failure(val throwable: Throwable?) : UnregisterExportResult()
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.sdk.CipherRepository
/**
@@ -10,4 +11,9 @@ interface SdkRepositoryFactory {
* Retrieves or creates a [CipherRepository] for use with the Bitwarden SDK.
*/
fun getCipherRepository(userId: String): CipherRepository
/**
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.
*/
fun getClientManagedTokens(userId: String?): ClientManagedTokens
}

View File

@@ -1,7 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.sdk.CipherRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
/**
@@ -9,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
*/
class SdkRepositoryFactoryImpl(
private val vaultDiskSource: VaultDiskSource,
private val bitwardenServiceClient: BitwardenServiceClient,
) : SdkRepositoryFactory {
override fun getCipherRepository(
userId: String,
@@ -17,4 +21,12 @@ class SdkRepositoryFactoryImpl(
userId = userId,
vaultDiskSource = vaultDiskSource,
)
override fun getClientManagedTokens(
userId: String?,
): ClientManagedTokens =
SdkTokenRepository(
userId = userId,
tokenProvider = bitwardenServiceClient.tokenProvider,
)
}

View File

@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.network.provider.TokenProvider
/**
* A user-scoped implementation of a Bitwarden SDK [ClientManagedTokens].
*/
class SdkTokenRepository(
private val userId: String?,
private val tokenProvider: TokenProvider,
) : ClientManagedTokens {
override suspend fun getAccessToken(): String? =
userId?.let { tokenProvider.getAccessToken(userId = it) }
}

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