Compare commits

..

179 Commits

Author SHA1 Message Date
Dave Severns
853f25bf57 [PM-12595] add notification badge to settings item in settings tab screen (#3976) 2024-09-27 11:37:30 -04:00
Dave Severns
70e75b910c [PM-12711] Auto-Fill selection bug fix (#3981) 2024-09-27 11:31:34 -04:00
David Perez
aaa541dd19 Simplify and clean up segmented button (#3977) 2024-09-26 16:11:44 -05:00
David Perez
80c1c26f5c Add reusable slider component (#3974) 2024-09-26 16:00:23 -05:00
Dave Severns
f349a72f72 [PM-12592] & [PM-12593] bottom nav notification dot (#3968) 2024-09-26 13:04:40 -04:00
David Perez
21e1d8b5bc Add reusable radio button (#3973) 2024-09-26 10:59:17 -05:00
David Perez
9213b4843f Disable stepper buttons when they reach the end of range (#3972) 2024-09-26 10:39:46 -05:00
David Perez
dad501d37e Remove unused span styles (#3970) 2024-09-26 08:57:37 -05:00
Oscar Hinton
61b09fe8f1 [PM-7587] Support conditionally loading local sdk (#3957) 2024-09-26 10:21:22 +02:00
David Perez
415aafb5e9 Add a reusable bitwarden loading indicator (#3971) 2024-09-25 19:53:24 -05:00
David Perez
251d13a832 Add reusable icon button components (#3969) 2024-09-25 17:37:38 -05:00
Dave Severns
40dd0e9776 PM-12594 Add observable flows to observe badge count changes when corresponding settings update (#3964) 2024-09-25 15:37:58 -04:00
David Perez
ad154b6c91 Add Bitwarden Horizontal Divider (#3967) 2024-09-25 14:27:24 -05:00
David Perez
caa0c613a5 PM-12631: Handle password re-prompt for accessibility autofill (#3965) 2024-09-25 13:16:22 -05:00
Andrew Haisting
6908111377 BITAU-173 Return null symmetric key when no users have enabled Authen… (#3961) 2024-09-25 10:01:07 -05:00
Dave Severns
0f009943b5 [PM-12604] Fix showing the biometric prompt when not needed adding account (#3962) 2024-09-25 10:49:48 -04:00
Andrew Haisting
4f34f6da21 BITAU-172 Rename Authenticator Bridge SDK (#3959) 2024-09-24 22:09:27 +00:00
David Perez
3d0dd2996e Replace usages of raw button with BitwardenFilledTonalButton (#3960) 2024-09-24 16:15:24 -05:00
Dave Severns
774b828cb0 PM-11535 Only add password to 2 factor login path if present (#3958) 2024-09-24 12:11:44 -04:00
David Perez
071a5330e6 Create new reusable FAB component (#3956) 2024-09-24 10:04:23 -05:00
aj-rosado
86bbf13175 [PM-10671] update password generator policy (#3936) 2024-09-24 17:00:30 +02:00
Andrew Haisting
87e223bc59 BITAU-104 Implement BridgeService (#3954) 2024-09-24 09:16:59 -05:00
David Perez
190f92ec67 Update switch to represent disabled state (#3955) 2024-09-23 15:30:54 -05:00
Álison Fernandes
ee7d00247e [PM-9328] Codeowners setup (#3926) 2024-09-23 12:43:55 -04:00
Dave Severns
f26374aae7 [PM-10118] update selected generator type when returning to main tab. (#3942) 2024-09-20 15:53:45 -04:00
David Perez
f68b4df9f9 PM-9901: Use sensitive text font for passwords (#3952) 2024-09-20 14:52:57 -05:00
Dave Severns
05d6c2f61e [PM-10622] Handle the skip unlock step in the onboarding, storing when the user selects to do so. (#3928) 2024-09-20 15:25:01 -04:00
renovate[bot]
8fe637d207 [deps]: Lock file maintenance (#3917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-20 13:09:24 -05:00
renovate[bot]
ad8702a34f [deps]: Update gh minor (#3916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-20 13:09:13 -05:00
David Perez
bcdbea13bf Password encoding and decoding handing in TwoFactorLoginNavigation (#3951) 2024-09-20 12:04:57 -05:00
github-actions[bot]
37b5dc7de3 Autosync Crowdin Translations (#3948) 2024-09-20 12:48:26 -04:00
Dave Severns
39cc24c13a PM-12321 Update typography for V3 design (#3946) 2024-09-20 11:34:30 -04:00
David Perez
d0c2bb5b7e PM-12397: Clear accessibility action when on launcher or Bitwarden App (#3947) 2024-09-20 09:44:30 -05:00
Dave Severns
5667a1cfd0 PM-10631 store when the user selects to turn auto fill on later (#3925) 2024-09-20 09:39:27 -04:00
André Bispo
3238279290 [PM-9755] Update duo AuthUrl error message to match other clients (#3945) 2024-09-20 14:21:53 +01:00
Dave Severns
73e158afd0 PM-10843 autofill setup screen animated image. (#3932) 2024-09-19 17:30:01 -04:00
Dave Severns
21b41fd4db PM-10617 + PM-11270 fixes after QA review (#3944) 2024-09-19 17:26:54 -04:00
David Perez
7fd16c9e10 Add FLAG_ACTIVITY_NEW_TASK flag for autofill tile to avoid crash (#3943) 2024-09-19 14:46:20 -05:00
David Perez
cb1e4f6b39 Update Androidx libraries (#3939) 2024-09-19 12:45:07 -05:00
Dave Severns
71d3ae9ee8 [PM-12319] Skip Auto-fill setup if already enabled. (#3934) 2024-09-19 13:36:11 -04:00
David Perez
c8b1b71960 Update APG to 8.6.1 (#3938) 2024-09-19 09:02:59 -05:00
Álison Fernandes
384be79838 Updated version name according to our pattern (#3940) 2024-09-19 09:15:15 -04:00
Andrew Haisting
cdf8b49d61 BITAU-108 Add unlockWithAuthenticatorSyncKey to VaultRepository (#3909) 2024-09-18 21:41:57 +00:00
David Perez
84f92f1b13 PM-12014: Enable accessibility autofill outside of debug builds (#3919) 2024-09-18 16:00:36 -05:00
David Perez
2068948035 PM-11486: Parse the Accessibility Nodes for username and password fields (#3935) 2024-09-18 15:35:15 -05:00
Andrew Haisting
f89b053d2e BITAU-103 Implement symmetric key creation and storage (#3905) 2024-09-18 13:47:10 -05:00
David Perez
4b53358c67 Bump version name to 2024.09.00 (#3937) 2024-09-18 11:19:23 -05:00
David Perez
2721f7293c PM-12297: Add accessibility service alert for autofill tile (#3931) 2024-09-18 10:08:04 -05:00
David Perez
1c2ccd5305 Add logic to ensure url has a valid https protocol (#3933) 2024-09-18 09:52:28 -05:00
Patrick Honkonen
4f55d622cb [PM-11884] Perform origin validation during FIDO 2 auth (#3896) 2024-09-18 10:41:52 -04:00
David Perez
74ae39a665 Update Firebase BOM to v33.3.0 (#3930) 2024-09-18 08:56:01 -05:00
Dave Severns
37f1da1ec2 PM-12076 remaining state based navigation linkup for onboarding (#3923) 2024-09-17 13:51:06 -04:00
David Perez
503c966177 Create reusable isAccessibilityServiceEnabled extension (#3929) 2024-09-17 11:53:37 -05:00
Dave Severns
76cc9c8579 [PM-12289] add onboarding status override to debug menu (#3927) 2024-09-17 11:52:29 -04:00
renovate[bot]
ed0494e159 [deps]: Update kotlin to v1.9.0 (#3849)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-17 09:53:42 -05:00
github-actions[bot]
fd8eda75b5 Autosync Crowdin Translations (#3914)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-09-17 09:29:06 -05:00
David Perez
765a9ebd74 PM-11485: Add routing logic to handle searching during accessibility autofill (#3924) 2024-09-17 09:04:00 -05:00
Dave Severns
39df103f12 [PM-10632] Setup complete screen for new onboarding (#3921) 2024-09-17 09:23:24 -04:00
David Perez
135a48b3c0 PM-12240: Remove unused permissions from the sync response (#3920) 2024-09-16 15:20:44 -05:00
David Perez
368ca49fd6 Add BitwardenLegacyAppComponents for legacy app component names (#3922) 2024-09-16 15:00:00 -05:00
Dave Severns
759e926588 PM-11464 Add onboarding status to user Account to allow for root navigation to onboarding flow. (#3878) 2024-09-16 12:40:56 -04:00
David Perez
3ecf1382b2 PM-11488: Add a switch to the autofill settings UI for enabling the accessibility service (#3911) 2024-09-16 09:14:00 -05:00
Dave Severns
190ba792a1 [PM-10905] Update JSON model to match API (#3913) 2024-09-13 13:22:21 -04:00
Andrew Haisting
d1f21d3585 BITAU-98 Add EncryptionUtils helper functions to the bridge SDK (#3888) 2024-09-12 15:02:01 -05:00
David Perez
4c1d55e9fe PM-11487: Initial accessibility service and processor for handling autofill (#3906) 2024-09-12 12:17:25 -05:00
Dave Severns
f544ccc3ef [PM-11741] shortcut navigation pt2 electric boogaloo (#3904) 2024-09-12 13:02:01 -04:00
David Perez
537c501b9b Update compose BOM to 2024.09.01 (#3907) 2024-09-11 15:31:25 -05:00
David Perez
2d3a47f3d1 Update Androidx libraries (#3908) 2024-09-11 15:31:11 -05:00
David Perez
6521848a8d PM-11485: Add routing for accessibility autofill (#3895) 2024-09-11 11:53:30 -05:00
David Perez
fe1f897d64 Update to detekt 1.23.7 (#3901) 2024-09-11 11:41:35 -05:00
Andrew Haisting
a5726fb72b BITAU-160 Update feature flag name for authenticator sync (#3903) 2024-09-11 09:12:35 -05:00
Dave Severns
8f30742908 PM-10845 add landscape layout for setup auto-fill (#3897) 2024-09-11 07:53:05 -04:00
David Perez
98bcff5e06 Add PreviewScreenSizes annotation to ignored list of annotations for tests (#3899) 2024-09-10 16:07:32 -05:00
Andrew Haisting
19596ea4c3 BITAU-102 Return null BridgeService when API level is below 12 (#3887) 2024-09-10 15:10:05 -05:00
Dave Severns
8dce8cd576 PM-10630 setup autofill UI and interactions set up (#3891) 2024-09-10 14:10:23 -04:00
Andrew Haisting
b94a1adda9 Use an aar to define bridge dependency (#3892) 2024-09-10 16:33:49 +00:00
David Perez
8489693d84 Update KSP to 2.0.20-1.0.25 (#3894) 2024-09-10 11:10:31 -05:00
Andrew Haisting
647b3e921b BITAU-168 Add Bridge SDK test dependencies (#3890) 2024-09-10 10:11:50 -05:00
aj-rosado
4e69ed57e8 [PM-10930] Fix password generator policies (#3853) 2024-09-10 16:31:58 +02:00
David Perez
95240a7ce3 Update to AGP 8.6.0 (#3889) 2024-09-10 09:22:24 -05:00
Andrew Haisting
19facaf8fd BITAU-164 add no-op version of BridgeService (#3884) 2024-09-09 15:36:34 -05:00
David Perez
bef05d5ed9 Simplify adding flags to debug menu (#3886) 2024-09-09 14:51:04 -05:00
David Perez
fa93985a2e Update Slider UI after Compose BOM update (#3885) 2024-09-09 14:50:48 -05:00
Andrew Haisting
b7544ef7f4 BITAU-96 Setup AIDL interface and files (#3880) 2024-09-09 10:43:33 -05:00
Dave Severns
fa2d7e0218 PM-11741 add password modal to root nav for generator shortcut specia… (#3883) 2024-09-09 11:42:19 -04:00
Andrew Haisting
c817253760 BITAU-108 Store Authenticator Sync Key (#3873) 2024-09-09 10:09:10 -05:00
github-actions[bot]
eb4e2ab31f Autosync Crowdin Translations (#3875) 2024-09-09 14:18:01 +00:00
David Perez
705153ea21 Add missing SerialName annotations (#3879) 2024-09-06 17:24:11 -05:00
Andrew Haisting
47fe78536f BITAU-165 add kotlinx serialization dependency to bridge (#3877) 2024-09-06 15:28:29 -05:00
David Perez
3f78ad6d6d Update the compose BOM to 2024.09.00 (#3874) 2024-09-06 12:45:12 -05:00
Dave Severns
e468ec695b PM-11604 Network layer for checking email token, nav to UI if needed. (#3862) 2024-09-06 13:40:00 -04:00
Dave Severns
ae349183e8 PM-11714 record if a user has signed in on device before. (#3876)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2024-09-06 13:18:35 -04:00
renovate[bot]
e039d5c3fb [deps]: Update gh minor (#3851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-05 13:14:47 -05:00
Andrew Haisting
b1ecf125d1 BITAU-107 Add Feature Flag and UI for Authenticator Syncing (#3847) 2024-09-05 13:13:48 -05:00
David Perez
69ca7649e2 Update the Gemfile.lock (#3872) 2024-09-05 13:13:40 -05:00
David Perez
22958aa5ed Update KotlinX Serialization to v1.7.2 (#3871) 2024-09-05 13:13:23 -05:00
David Perez
7ebbe700f0 Update immutable collection to v0.3.8 (#3870) 2024-09-05 12:33:55 -05:00
Andrew Haisting
de48bb37d7 BITAU-96 Setup Bridge SDK (#3857) 2024-09-05 12:22:49 -05:00
David Perez
1dd19a8b2d Update to latest Kotlin v2.0.20 (#3869) 2024-09-05 11:59:08 -05:00
David Perez
733053b548 PM-11484: Add logic to parse URI from AccessibilityNodeInfo (#3864) 2024-09-05 11:20:11 -05:00
David Perez
f10a2b15ba Remove unsued shouldFinishOnComplete property (#3865) 2024-09-04 17:50:31 -05:00
David Perez
c02afb6714 PM-11643: Add LauncherPackageNameManager for tracking launcher apps (#3861) 2024-09-04 14:12:09 -05:00
Dave Severns
b672418c45 PM-11479 Expired link UI (#3854) 2024-09-04 13:07:47 -04:00
David Perez
c017c1b10c PM-11642: Security stamp soft logout (#3859) 2024-09-04 11:54:31 -05:00
David Perez
e3a4a7b153 Relocate the accessibility package into the autofill package (#3860) 2024-09-04 11:30:43 -05:00
David Perez
36f13e44a3 PM-11616: Manage totp logic in AutofillTotpManager (#3856) 2024-09-03 15:54:37 -05:00
Mathias Mader
2597c44117 [PM-11398] [PM-10872] - Fix: Search fields not showing text correctly (#3840) 2024-09-03 14:37:30 -04:00
github-actions[bot]
ae685c73d2 Autosync Crowdin Translations (#3845) 2024-09-03 18:22:58 +00:00
Dave Severns
2b5337c4ff PM-11214 change navoptions to support single top without retaining VM (#3843) 2024-09-03 14:14:13 -04:00
David Perez
7e5203efa5 PM-11483: Create AutofillTileService (#3844) 2024-08-30 09:50:54 -05:00
Dave Severns
17c579bfc2 PM-11387 on new create account with email verification, attempt login… (#3842) 2024-08-29 14:07:42 -04:00
Dave Severns
3c39d8beac PM-11224 Add menu to update feature flags with overridden values in real time (#3838) 2024-08-29 14:07:21 -04:00
Carlos Gonçalves
2a057bb1fb [PM-10762] Remove Passkey button should be hidden when I have Can View permission (#3829) 2024-08-29 16:40:01 +01:00
Patrick Honkonen
f778d7ecd1 [PM-10902] Base64 encode sensitive 2FA nav args (#3841) 2024-08-29 08:45:54 -04:00
Dave Severns
4c983525d3 PM-11310 handle email registration special circumstance after successful login (#3831) 2024-08-28 13:27:58 -04:00
Dave Severns
e32a9f303d PM-11394 String parse issue with app link (#3839) 2024-08-27 17:02:27 -04:00
David Perez
522e3bb939 PM-11354: TDE unlock since we already have the correct key from the identity service (#3835) 2024-08-27 08:54:51 -05:00
David Perez
0676cf8826 Update internal BitwardenTextButton padding to be consistent with all Bitwarden Buttons (#3834) 2024-08-26 17:10:02 -05:00
David Perez
5173dfd424 Carousel buttons should be full width (#3833) 2024-08-26 17:09:39 -05:00
David Perez
5bc31448b4 Update Firebase BOM to 33.2.0 (#3832) 2024-08-26 14:20:59 -05:00
David Perez
e9a7136a9a PM-10899: Only display the TDE UI if a selection has not yet been made (#3823) 2024-08-26 12:53:35 -05:00
David Perez
c36d0851ca Update the compose BOM (2024.08.00) (#3830) 2024-08-26 12:53:17 -05:00
Wu Nan
ace5f19375 [PM-11307] Fix typo in method name: shouldShouldRequestPermissionRationale (#3821) 2024-08-26 10:58:19 -04:00
A. Bubnov
88b40cfd10 [PM-10685] Support keyboard Done event as CTA Unlock on Pin\Master Password unlock screen (#3691) 2024-08-26 10:57:48 -04:00
David Perez
38e693f92c Simplify manual unlock check (#3824) 2024-08-26 09:07:23 -05:00
mpbw2
9dbb40f33b Add My Vault and Password Generator Quick Settings tiles (#3764) 2024-08-26 09:53:10 -04:00
Dave Severns
76a3265bbb PM-10692 pass a generated password back to the complete registration … (#3806) 2024-08-26 08:56:28 -04:00
David Perez
666c165b6f PM-10899: Fix user not being logged out properly on app restart (#3822) 2024-08-23 13:49:49 -05:00
Dave Severns
9db09c18cc [PM-11270] hide new UI in complete registration screen behind flag pt. 2 (#3812) 2024-08-23 12:52:21 -04:00
David Perez
b7330392cc PM-11299: Update the userState to properly parse the hasManageResetPasswordPermission flag (#3820) 2024-08-23 11:11:20 -05:00
David Perez
162da64567 Minor formatting an import cleanup (#3819) 2024-08-23 10:49:59 -05:00
github-actions[bot]
09f497ca9b Autosync Crowdin Translations (#3815) 2024-08-23 10:25:44 -04:00
André Bispo
87d7143cc8 [PM-6702] AppLink new redirect path (#3814) 2024-08-23 13:19:19 +00:00
David Perez
23bcfad717 PM-11273: Update the 'useKeyConnector' with 'keyConnectorEnabled' (#3813) 2024-08-22 17:05:46 -05:00
David Perez
f1f16cfee5 PM-11265: Remove the leave organization API (#3811) 2024-08-22 15:17:54 -05:00
Dave Severns
82d3b44712 [PM-11270] Hide all new UI behind onboarding flow flag. (#3810) 2024-08-22 16:06:54 -04:00
David Perez
b56a21b6e5 PM-10917: Fix crash caused when adding an item from a collection (#3809) 2024-08-22 13:48:46 -05:00
David Perez
eb2ba8e598 PM-11264: Ensure user has valid timeout action after migrating to Key Connector (#3807) 2024-08-22 13:48:26 -05:00
David Perez
91f039ecb6 Simplify common login helper methods (#3805) 2024-08-22 11:22:07 -05:00
Dave Severns
0d6aeee870 PM-10617 modify pw strength indicator to show min chars if required. (#3793) 2024-08-22 11:13:23 -04:00
David Perez
a0a5070ac7 PM-11254: Add logic for logging in with Key Connector (#3802) 2024-08-21 16:13:36 -05:00
David Perez
e7bd966e94 PM-11256: Add RootNav logic to display Remove Password Screen (#3803) 2024-08-21 15:53:27 -05:00
Dave Severns
075956ce17 PM-10617 + PM-10637 update complete registration screen to match new onboarding design (#3787) 2024-08-21 15:32:28 -04:00
David Perez
13b256d4e9 PM-11155: Add logic for handling remove password flow (#3801) 2024-08-21 14:30:27 -05:00
David Perez
5761e9510a PM-11248: Add isUsingKeyConnector flag to UserState (#3798) 2024-08-21 14:24:34 -05:00
David Perez
3b3b9ef33b Fix IllegalArgumentException in test (#3799) 2024-08-21 12:33:33 -05:00
David Perez
17fd3ec0f0 PM-11226: Wrap Key Connector APIs (#3794) 2024-08-21 12:26:20 -05:00
David Perez
43a6495b98 PM-11236: Add build type and flavor to the user agent (#3797) 2024-08-21 09:36:45 -05:00
Dave Severns
86dabea39f PM-11192 update check email screen to new design (#3788) 2024-08-20 18:17:03 -04:00
David Perez
8d08b5f7c5 PM-11223: Enable remote confg for email verification feature (#3792) 2024-08-20 15:19:23 -05:00
Matt Bishop
13c29c8296 Update public suffix list (#3790) 2024-08-20 14:18:00 -04:00
David Perez
eac5516a94 PM-11154: Create basic Remove Master Password UI (#3782) 2024-08-20 13:15:44 -05:00
renovate[bot]
88b674f54c [deps]: Lock file maintenance (#3786) 2024-08-20 12:27:51 -04:00
renovate[bot]
bcc24a2e25 [deps]: Update sonarsource/sonarcloud-github-action action to v3 (#3785) 2024-08-20 12:25:30 -04:00
renovate[bot]
e14f399e2d [deps]: Update github/codeql-action action to v3.26.3 (#3784) 2024-08-20 12:13:39 -04:00
André Bispo
ad2c575b39 [PM-9933] Update marketing copy (#3778) 2024-08-20 08:33:53 -04:00
Dave Severns
57c2e7ee4e [Pm 10616] create account start design (#3751) 2024-08-19 17:47:45 -04:00
Patrick Honkonen
55b57a605e [PM-10282] Update build artifact names (#3774) 2024-08-19 16:42:48 -04:00
David Perez
397c78b4af PM-11140: Update hasMasterPassword logic for key connectors (#3775) 2024-08-19 15:13:31 -05:00
David Perez
9e372c29d1 Update to the latest Bitwarden SDK (#3779) 2024-08-19 15:12:30 -05:00
David Perez
82fd7f01f8 PM-10954: Update the key connector APIs to use the correct url and responses (#3781) 2024-08-19 15:12:09 -05:00
Patrick Honkonen
a15b84a5bf [PM-10282] Default to last active account for passkey creation (#3780) 2024-08-19 15:10:31 -04:00
David Perez
5f46423638 Apply formatter to the app (#3777) 2024-08-19 13:43:45 -05:00
renovate[bot]
8aebd36465 [deps]: Update gradle minor (#3771) 2024-08-19 09:40:42 -04:00
renovate[bot]
b4f864d89c [deps]: Update kotlin (#3770) 2024-08-19 09:39:03 -04:00
Patrick Honkonen
8c8db78da6 [PM-10883] Support deserializing Forward Email service type details (#3739) 2024-08-19 09:02:57 -04:00
renovate[bot]
b18d9f53c6 [deps]: Lock file maintenance (#3772) 2024-08-19 12:59:25 +00:00
Dave Severns
7134d89352 PM-10986 explicitly keep AuthenticatedKeyConnectionApi to prevent cla… (#3765) 2024-08-16 15:45:03 -04:00
Patrick Honkonen
5a7dc198dd [PM-10884] Catch ProviderException when generating a secure key (#3733) 2024-08-16 15:13:41 -04:00
renovate[bot]
7dbfcfdea2 [deps]: Lock file maintenance (#3760) 2024-08-16 14:10:09 -04:00
renovate[bot]
b56ccd1bab [deps]: Update gradle/actions action to v4 (#3759) 2024-08-16 12:58:03 -04:00
renovate[bot]
f05828c87d [deps]: Update gh minor (#3758) 2024-08-16 12:31:59 -04:00
David Perez
48817f0fe4 Simplify error responses (#3762) 2024-08-16 15:07:56 +00:00
github-actions[bot]
3bed2581af Autosync Crowdin Translations (#3756) 2024-08-16 14:27:06 +00:00
André Bispo
acb125b2b9 [PM-6702] 6# Complete registration screen (#3622) 2024-08-16 15:16:36 +01:00
David Perez
72e5aedccd Rename APIs for extra specificity (#3755) 2024-08-16 09:04:10 -05:00
Shannon Draeker
9148a750a5 PM-10874: Prompt for biometrics after switching accounts (#3753) 2024-08-16 09:45:32 -04:00
David Perez
d4600c5c83 PM-10956: Add support for leave organization API (#3754) 2024-08-16 08:37:07 -05:00
David Perez
8094b3fd22 PM-10954: Add network APIs for key-connector (#3752) 2024-08-16 08:36:42 -05:00
574 changed files with 31504 additions and 5928 deletions

5
.github/CODEOWNERS vendored
View File

@@ -5,7 +5,10 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Default file owners.
# * @bitwarden/tech-leads
* @bitwarden/team-android @brian-livefront @david-livefront @dseverns-livefront @ahaisting-livefront
# Actions and workflow changes.
.github/workflows @bitwarden/dept-development-mobile
# Auth
# app/src/main/java/com/x8bit/bitwarden/data/auth @bitwarden/team-auth-dev

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -62,13 +62,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
uses: ruby/setup-ruby@f321cf5a4d1533575411f8752cf25b86478b0442 # v1.193.0
with:
bundler-cache: true
@@ -92,14 +92,14 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: ["prod", "qa"]
variant: ["prod", "dev"]
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Configure Ruby
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
uses: ruby/setup-ruby@f321cf5a4d1533575411f8752cf25b86478b0442 # v1.193.0
with:
bundler-cache: true
@@ -150,7 +150,7 @@ jobs:
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -172,7 +172,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -230,14 +230,14 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:${{ env.PLAY_BETA_KEY_PASSWORD }}
- name: Generate QA Play Store APKs
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
bundle exec fastlane assembleDebugApks
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab
@@ -245,7 +245,7 @@ jobs:
- name: Upload beta Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab
@@ -253,7 +253,7 @@ jobs:
- name: Upload release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk
@@ -261,18 +261,18 @@ jobs:
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload other .apk artifact
- name: Upload debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden-${{ matrix.variant }}.apk
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk
if-no-files-found: error
@@ -280,70 +280,70 @@ jobs:
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk" \
> ./bw-android-apk-sha256.txt
> ./com.x8bit.bitwarden.apk-sha256.txt
- name: Create checksum for beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk" \
> ./bw-android-beta-apk-sha256.txt
> ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Create checksum for release .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab" \
> ./bw-android-aab-sha256.txt
> ./com.x8bit.bitwarden.aab-sha256.txt
- name: Create checksum for beta .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab" \
> ./bw-android-beta-aab-sha256.txt
> ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Create checksum for other .apk artifact
- name: Create checksum for Debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk" \
> ./bw-android-${{ matrix.variant }}-apk-sha256.txt
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-android-apk-sha256.txt
path: ./bw-android-apk-sha256.txt
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-android-beta-apk-sha256.txt
path: ./bw-android-beta-apk-sha256.txt
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-android-aab-sha256.txt
path: ./bw-android-aab-sha256.txt
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-android-beta-aab-sha256.txt
path: ./bw-android-beta-aab-sha256.txt
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for other
- name: Upload .apk SHA file for debug
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-android-${{ matrix.variant }}-apk-sha256.txt
path: ./bw-android-${{ matrix.variant }}-apk-sha256.txt
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
@@ -387,7 +387,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Configure Ruby
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
uses: ruby/setup-ruby@f321cf5a4d1533575411f8752cf25b86478b0442 # v1.193.0
with:
bundler-cache: true
@@ -424,7 +424,7 @@ jobs:
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -446,7 +446,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -469,7 +469,6 @@ jobs:
keyAlias:bitwarden \
keyPassword:"${{ env.FDROID_STORE_PASSWORD }}"
# Generate the F-Droid APK for publishing
- name: Generate F-Droid Beta Artifacts
env:
FDROID_BETA_KEYSTORE_PASSWORD: ${{ secrets.FDROID_BETA_KEYSTORE_PASSWORD }}
@@ -482,7 +481,7 @@ jobs:
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk
@@ -491,32 +490,32 @@ jobs:
- name: Create checksum for F-Droid artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk" \
> ./bw-fdroid-apk-sha256.txt
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-fdroid-apk-sha256.txt
path: ./bw-fdroid-apk-sha256.txt
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: com.x8bit.bitwarden-fdroid-beta.apk
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk
if-no-files-found: error
- name: Create checksum for F-Droid Beta artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk" \
> ./bw-fdroid-beta-apk-sha256.txt
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: bw-fdroid-beta-apk-sha256.txt
path: ./bw-fdroid-beta-apk-sha256.txt
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin

View File

@@ -30,7 +30,7 @@ jobs:
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Download translations
uses: crowdin/github-action@6ed209d411599a981ccb978df3be9dc9b8a81699 # v2.1.1
uses: crowdin/github-action@91d52b545f82cb88e86c3002a443de22df77fa16 # v2.1.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -29,7 +29,7 @@ jobs:
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@6ed209d411599a981ccb978df3be9dc9b8a81699 # v2.1.1
uses: crowdin/github-action@91d52b545f82cb88e86c3002a443de22df77fa16 # v2.1.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -31,7 +31,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@6c56658230f79c227a55120e9b24845d574d5225 # 2.0.31
uses: checkmarx/ast-github-action@9fda5a4a2c297608117a5a56af424502a9192e57 # 2.0.34
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
@@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8
with:
sarif_file: cx_result.sarif
@@ -66,7 +66,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@e44258b109568baa0df60ed515909fc6c72cba92 # v2.3.0
uses: sonarsource/sonarcloud-github-action@eb211723266fe8e83102bac7361f0a05c3ac1d1b # v3.0.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -36,7 +36,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -58,12 +58,12 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
uses: ruby/setup-ruby@f321cf5a4d1533575411f8752cf25b86478b0442 # v1.193.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}

View File

@@ -10,17 +10,17 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.961.0)
aws-sdk-core (3.201.3)
aws-partitions (1.975.0)
aws-sdk-core (3.205.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (1.91.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-s3 (1.162.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
@@ -134,7 +134,7 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.0)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
@@ -155,12 +155,12 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.6)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.2)
jwt (2.8.2)
jwt (2.9.0)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
@@ -179,8 +179,7 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.9)
strscan
rexml (3.3.7)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
@@ -193,11 +192,10 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
time (0.3.0)
time (0.4.0)
date
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
@@ -205,15 +203,15 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)

View File

@@ -28,6 +28,7 @@
2. Create a `user.properties` file in the root directory of the project and add the following properties:
- `gitHubToken`: A "classic" Github Personal Access Token (PAT) with the `read:packages` scope (ex: `gitHubToken=gph_xx...xx`). These can be generated by going to the [Github tokens page](https://github.com/settings/tokens). See [the Github Packages user documentation concerning authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for more details.
- `localSdk`: A boolean value to determine if the SDK should be loaded from the local maven artifactory (ex: `localSdk=true`). This is particularly useful when developing new SDK capabilities. Review [Linking SDK to clients](https://contributing-docs.pages.dev/getting-started/sdk/#linking-sdk-to-clients) for more details.
3. Setup the code style formatter:

View File

@@ -2,6 +2,8 @@ import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFile
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
import com.google.gms.googleservices.GoogleServicesTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
@@ -20,6 +22,16 @@ plugins {
alias(libs.plugins.sonarqube)
}
/**
* Loads local user-specific build properties that are not checked into source control.
*/
val userProperties = Properties().apply {
val buildPropertiesFile = File(rootDir, "user.properties")
if (buildPropertiesFile.exists()) {
FileInputStream(buildPropertiesFile).use { load(it) }
}
}
android {
namespace = "com.x8bit.bitwarden"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -29,7 +41,7 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "2024.06.00"
versionName = "2024.9.0"
setProperty("archivesBaseName", "com.x8bit.bitwarden")
@@ -61,6 +73,8 @@ android {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
}
// Beta and Release variants are identical except beta has a different package name
@@ -72,6 +86,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
release {
isDebuggable = false
@@ -80,6 +96,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}
@@ -124,11 +142,23 @@ kotlin {
}
}
configurations.all {
resolutionStrategy.dependencySubstitution {
if ((userProperties["localSdk"] as String?).toBoolean()) {
substitute(module("com.bitwarden:sdk-android"))
.using(module("com.bitwarden:sdk-android:LOCAL"))
}
}
}
dependencies {
fun standardImplementation(dependencyNotation: Any) {
add("standardImplementation", dependencyNotation)
}
// TODO: this should use a versioned AAR instead of referencing a local AAR BITAU-94
implementation(files("libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.autofill)
@@ -213,6 +243,7 @@ kover {
annotatedBy(
// Compose previews
"androidx.compose.ui.tooling.preview.Preview",
"androidx.compose.ui.tooling.preview.PreviewScreenSizes",
// Manually excluded classes/files/etc.
"com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage",
)
@@ -302,4 +333,4 @@ tasks {
getByName("sonar") {
dependsOn("check")
}
}
}

View File

@@ -62,18 +62,28 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.pw" />
<data android:host="bitwarden.eu" />
<data android:host="vault.bitwarden.com" />
<data android:host="vault.bitwarden.eu" />
<data android:host="*.bitwarden.pw" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".AccessibilityActivity"
android:exported="false"
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay" />
<activity
android:name=".AutofillTotpCopyActivity"
android:exported="true"
@@ -158,6 +168,26 @@
</intent-filter>
</service>
<!--
The AccessibilityService name below refers to the legacy Xamarin app's service name. This
must always match in order for the app to properly query if it is providing accessibility
services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.Accessibility.AccessibilityService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service" />
</service>
<!--
The CredentialProviderService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing credential
@@ -189,6 +219,60 @@
android:value="true" />
</service>
<!--
The AutofillTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing autofill
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.AutofillTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/autofill"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!--
The GeneratorTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing generator
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.GeneratorTileService"
android:exported="true"
android:icon="@drawable/ic_generator"
android:label="@string/password_generator"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!--
The MyVaultTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing vault
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.MyVaultTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/my_vault"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
@@ -199,6 +283,10 @@
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* An activity to be launched and then immediately closed so that the OS Shade can be collapsed
* after the user clicks on the Autofill Quick Tile.
*/
@OmitFromCoverage
class AccessibilityActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
finish()
}
}

View File

@@ -3,37 +3,62 @@ package com.x8bit.bitwarden
import android.app.Service
import android.content.Intent
import android.os.Build
import androidx.annotation.Keep
import androidx.core.app.AppComponentFactory
import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService
import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService
import com.x8bit.bitwarden.data.autofill.fido2.BitwardenFido2ProviderService
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.AutofillService"
private const val LEGACY_CREDENTIAL_SERVICE_NAME =
"com.x8bit.bitwarden.Autofill.CredentialProviderService"
import com.x8bit.bitwarden.data.tiles.BitwardenAutofillTileService
import com.x8bit.bitwarden.data.tiles.BitwardenGeneratorTileService
import com.x8bit.bitwarden.data.tiles.BitwardenVaultTileService
/**
* A factory class that allows us to intercept when a manifest element is being instantiated
* and modify various characteristics before initialization.
*/
@Suppress("unused")
@Keep
@OmitFromCoverage
class BitwardenAppComponentFactory : AppComponentFactory() {
/**
* Used to intercept when the [BitwardenAutofillService] or [BitwardenFido2ProviderService] is
* being instantiated and modify which service is created. This is required because the
* [className] used in the manifest must match the legacy Xamarin app service name but the
* service name in this app is different.
* Used to intercept when certain legacy services are being instantiated and modify which
* service is created. This is required because the [className] used in the manifest must match
* the legacy Xamarin app service name but the service name in this app is different.
*
* Services currently being managed:
* * [BitwardenAccessibilityService]
* * [BitwardenAutofillService]
* * [BitwardenAutofillTileService]
* * [BitwardenFido2ProviderService]
* * [BitwardenVaultTileService]
* * [BitwardenGeneratorTileService]
*/
override fun instantiateServiceCompat(
cl: ClassLoader,
className: String,
intent: Intent?,
): Service = when (className) {
LEGACY_ACCESSIBILITY_SERVICE_NAME -> {
super.instantiateServiceCompat(
cl,
BitwardenAccessibilityService::class.java.name,
intent,
)
}
LEGACY_AUTOFILL_SERVICE_NAME -> {
super.instantiateServiceCompat(cl, BitwardenAutofillService::class.java.name, intent)
}
LEGACY_AUTOFILL_TILE_SERVICE_NAME -> {
super.instantiateServiceCompat(
cl,
BitwardenAutofillTileService::class.java.name,
intent,
)
}
LEGACY_CREDENTIAL_SERVICE_NAME -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
super.instantiateServiceCompat(
@@ -48,6 +73,18 @@ class BitwardenAppComponentFactory : AppComponentFactory() {
}
}
LEGACY_VAULT_TILE_SERVICE_NAME -> {
super.instantiateServiceCompat(cl, BitwardenVaultTileService::class.java.name, intent)
}
LEGACY_GENERATOR_TILE_SERVICE_NAME -> {
super.instantiateServiceCompat(
cl,
BitwardenGeneratorTileService::class.java.name,
intent,
)
}
else -> super.instantiateServiceCompat(cl, className, intent)
}
}

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden
/**
* The legacy name for the accessibility service.
*/
const val LEGACY_ACCESSIBILITY_SERVICE_NAME: String =
"com.x8bit.bitwarden.Accessibility.AccessibilityService"
/**
* The legacy name for the autofill service.
*/
const val LEGACY_AUTOFILL_SERVICE_NAME: String = "com.x8bit.bitwarden.Autofill.AutofillService"
/**
* The legacy name for the accessibility autofill tile service.
*/
const val LEGACY_AUTOFILL_TILE_SERVICE_NAME: String = "com.x8bit.bitwarden.AutofillTileService"
/**
* The legacy name for the credential service.
*/
const val LEGACY_CREDENTIAL_SERVICE_NAME: String =
"com.x8bit.bitwarden.Autofill.CredentialProviderService"
/**
* The legacy name for the generator tile service.
*/
const val LEGACY_GENERATOR_TILE_SERVICE_NAME: String = "com.x8bit.bitwarden.GeneratorTileService"
/**
* The legacy name for the vault tile service.
*/
const val LEGACY_VAULT_TILE_SERVICE_NAME: String = "com.x8bit.bitwarden.MyVaultTileService"

View File

@@ -2,7 +2,10 @@ package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -11,17 +14,20 @@ import androidx.compose.runtime.getValue
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
@@ -33,22 +39,29 @@ class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var accessibilityActivityManager: AccessibilityActivityManager
@Inject
lateinit var autofillActivityManager: AutofillActivityManager
@Inject
lateinit var autofillCompletionManager: AutofillCompletionManager
@Inject
lateinit var accessibilityCompletionManager: AccessibilityCompletionManager
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
observeViewModelEvents()
if (savedInstanceState == null) {
mainViewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -66,11 +79,33 @@ class MainActivity : AppCompatActivity() {
}
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
val navController = rememberNavController()
EventsEffect(viewModel = mainViewModel) { event ->
when (event) {
is MainEvent.CompleteAccessibilityAutofill -> {
handleCompleteAccessibilityAutofill(event)
}
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
is MainEvent.ShowToast -> {
Toast
.makeText(
baseContext,
event.message.invoke(resources),
Toast.LENGTH_SHORT,
)
.show()
}
}
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider {
BitwardenTheme(theme = state.theme) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false },
navController = navController,
)
}
}
@@ -93,16 +128,27 @@ class MainActivity : AppCompatActivity() {
currentFocus?.clearFocus()
}
private fun observeViewModelEvents() {
mainViewModel
.eventFlow
.onEach { event ->
when (event) {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
}
}
.launchIn(lifecycleScope)
override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchTouchEvent(event)
override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchKeyEvent(event)
private fun sendOpenDebugMenuEvent() {
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
}
private fun handleCompleteAccessibilityAutofill(
event: MainEvent.CompleteAccessibilityAutofill,
) {
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = this,
cipherView = event.cipherView,
)
}
private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) {

View File

@@ -6,8 +6,10 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
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
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
@@ -17,11 +19,15 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
@@ -30,10 +36,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import javax.inject.Inject
@@ -46,14 +54,16 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val savedStateHandle: SavedStateHandle,
private val clock: Clock,
) : BaseViewModel<MainState, MainEvent, MainAction>(
@@ -77,6 +87,12 @@ class MainViewModel @Inject constructor(
.onEach { specialCircumstance = it }
.launchIn(viewModelScope)
accessibilitySelectionManager
.accessibilitySelectionFlow
.map { MainAction.Internal.AccessibilitySelectionReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
autofillSelectionManager
.autofillSelectionFlow
.onEach { trySendAction(MainAction.Internal.AutofillSelectionReceive(it)) }
@@ -126,10 +142,27 @@ class MainViewModel @Inject constructor(
}
}
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
val userState = authRepository
.userStateFlow
.first()
userState
?.accounts
?.forEach {
settingsRepository.storeUserHasLoggedInValue(it.userId)
}
}
}
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.Internal.AccessibilitySelectionReceive -> {
handleAccessibilitySelectionReceive(action)
}
is MainAction.Internal.AutofillSelectionReceive -> {
handleAutofillSelectionReceive(action)
}
@@ -140,9 +173,20 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
}
}
private fun handleOpenDebugMenu() {
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleAccessibilitySelectionReceive(
action: MainAction.Internal.AccessibilitySelectionReceive,
) {
sendEvent(MainEvent.CompleteAccessibilityAutofill(cipherView = action.cipherView))
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
@@ -206,14 +250,7 @@ class MainViewModel @Inject constructor(
}
completeRegistrationData != null -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
}
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CompleteRegistration(
completeRegistrationData = completeRegistrationData,
timestamp = clock.millis(),
)
handleCompleteRegistrationData(completeRegistrationData)
}
autofillSaveItem != null -> {
@@ -290,6 +327,49 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.Recreate)
garbageCollectionManager.tryCollect()
}
private fun handleCompleteRegistrationData(data: CompleteRegistrationData) {
viewModelScope.launch {
// Attempt to load the environment for the user if they have a pre-auth environment
// saved.
environmentRepository.loadEnvironmentForEmail(userEmail = data.email)
// Determine if the token is still valid.
val emailTokenResult = authRepository.validateEmailToken(
email = data.email,
token = data.verificationToken,
)
when (emailTokenResult) {
is EmailTokenResult.Error -> {
sendEvent(
MainEvent.ShowToast(
message = emailTokenResult
.message
?.asText()
?: R.string.there_was_an_issue_validating_the_registration_token
.asText(),
),
)
}
EmailTokenResult.Expired -> {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance
.RegistrationEvent
.ExpiredRegistrationLink
}
EmailTokenResult.Success -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
}
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.RegistrationEvent.CompleteRegistration(
completeRegistrationData = data,
timestamp = clock.millis(),
)
}
}
}
}
}
/**
@@ -315,10 +395,23 @@ sealed class MainAction {
*/
data class ReceiveNewIntent(val intent: Intent) : MainAction()
/**
* Receive event to open the debug menu.
*/
data object OpenDebugMenu : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : MainAction() {
/**
* Indicates the user has manually selected the given [cipherView] for accessibility
* autofill.
*/
data class AccessibilitySelectionReceive(
val cipherView: CipherView,
) : Internal()
/**
* Indicates the user has manually selected the given [cipherView] for autofill.
*/
@@ -356,6 +449,12 @@ sealed class MainAction {
* Represents events that are emitted by the [MainViewModel].
*/
sealed class MainEvent {
/**
* Event indicating that the user has chosen the given [cipherView] for accessibility autofill
* and that the process is ready to complete.
*/
data class CompleteAccessibilityAutofill(val cipherView: CipherView) : MainEvent()
/**
* Event indicating that the user has chosen the given [cipherView] for autofill and that the
* process is ready to complete.
@@ -366,4 +465,14 @@ sealed class MainEvent {
* Event indicating that the UI should recreate itself.
*/
data object Recreate : MainEvent()
/**
* Navigate to the debug menu.
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : MainEvent()
}

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@@ -11,6 +12,13 @@ import kotlinx.coroutines.flow.Flow
*/
@Suppress("TooManyFunctions")
interface AuthDiskSource {
/**
* The currently persisted authenticator sync symmetric key. This key is used for
* encrypting IPC traffic.
*/
var authenticatorSyncSymmetricKey: ByteArray?
/**
* Retrieves a unique ID for the application that is stored locally. This will generate a new
* one if it does not yet exist and it will only be reset for new installs or when clearing
@@ -45,16 +53,43 @@ interface AuthDiskSource {
*/
fun clearData(userId: String)
/**
* Get the authenticator sync unlock key. Null means there is no key, which means the user
* has not enabled authenticator syncing
*/
fun getAuthenticatorSyncUnlockKey(userId: String): String?
/**
* Store the authenticator sync unlock key. Storing a null key effectively disables
* authenticator syncing.
*/
fun storeAuthenticatorSyncUnlockKey(userId: String, authenticatorSyncUnlockKey: String?)
/**
* Retrieves the state indicating that the user should use a key connector.
*/
fun getShouldUseKeyConnector(userId: String): Boolean?
/**
* Retrieves the state indicating that the user should use a key connector as a flow.
*/
fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?>
/**
* Stores the boolean indicating that the user should use a key connector.
*/
fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?)
/**
* Retrieves the state indicating that the user has completed login with TDE.
*/
fun getIsTdeLoginComplete(userId: String): Boolean?
/**
* Stores the boolean indicating that the user has completed login with TDE.
*/
fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?)
/**
* Retrieves the state indicating that the user has chosen to trust this device.
*
@@ -255,4 +290,20 @@ interface AuthDiskSource {
* Stores the [accountTokens] for the given [userId].
*/
fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?)
/**
* Gets the onboarding status for the given [userId].
*/
fun getOnboardingStatus(userId: String): OnboardingStatus?
/**
* Stores the [onboardingStatus] for the given [userId].
*/
fun storeOnboardingStatus(userId: String, onboardingStatus: OnboardingStatus?)
/**
* Emits updates that track [getOnboardingStatus]. This will replay the last known value,
* if any exists.
*/
fun getOnboardingStatusFlow(userId: String): Flow<OnboardingStatus?>
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource
@@ -18,6 +19,8 @@ import java.util.UUID
// These keys should be encrypted
private const val ACCOUNT_TOKENS_KEY = "accountTokens"
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetric"
private const val AUTHENTICATOR_SYNC_UNLOCK_KEY = "authenticatorSyncUnlock"
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
private const val USER_AUTO_UNLOCK_KEY_KEY = "userKeyAutoUnlock"
private const val DEVICE_KEY_KEY = "deviceKey"
@@ -39,7 +42,9 @@ private const val TWO_FACTOR_TOKEN_KEY = "twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "keyHash"
private const val POLICIES_KEY = "policies"
private const val SHOULD_TRUST_DEVICE_KEY = "shouldTrustDevice"
private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
/**
* Primary implementation of [AuthDiskSource].
@@ -57,12 +62,16 @@ class AuthDiskSourceImpl(
AuthDiskSource {
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val mutableShouldUseKeyConnectorFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
private val mutableAccountTokensFlowMap =
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
private val mutableOnboardingStatusFlowMap =
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
override var userState: UserStateJson?
@@ -85,6 +94,14 @@ class AuthDiskSourceImpl(
migrateAccountTokens()
}
override var authenticatorSyncSymmetricKey: ByteArray?
set(value) {
val asString = value?.let { value.toString(Charsets.ISO_8859_1) }
putEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY, asString)
}
get() = getEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY)
?.toByteArray(Charsets.ISO_8859_1)
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
@@ -124,11 +141,30 @@ class AuthDiskSourceImpl(
storePolicies(userId = userId, policies = null)
storeAccountTokens(userId = userId, accountTokens = null)
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = null)
storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = null)
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
}
override fun getAuthenticatorSyncUnlockKey(userId: String): String? =
getEncryptedString(AUTHENTICATOR_SYNC_UNLOCK_KEY.appendIdentifier(userId))
override fun storeAuthenticatorSyncUnlockKey(
userId: String,
authenticatorSyncUnlockKey: String?,
) {
putEncryptedString(
key = AUTHENTICATOR_SYNC_UNLOCK_KEY.appendIdentifier(userId),
value = authenticatorSyncUnlockKey,
)
}
override fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?> =
getMutableShouldUseKeyConnectorFlowMap(userId = userId)
.onSubscription { emit(getShouldUseKeyConnector(userId = userId)) }
override fun getShouldUseKeyConnector(
userId: String,
): Boolean? = getBoolean(key = USES_KEY_CONNECTOR.appendIdentifier(userId))
@@ -138,6 +174,15 @@ class AuthDiskSourceImpl(
key = USES_KEY_CONNECTOR.appendIdentifier(userId),
value = shouldUseKeyConnector,
)
getMutableShouldUseKeyConnectorFlowMap(userId = userId).tryEmit(shouldUseKeyConnector)
}
override fun getIsTdeLoginComplete(
userId: String,
): Boolean? = getBoolean(key = TDE_LOGIN_COMPLETE.appendIdentifier(userId))
override fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?) {
putBoolean(TDE_LOGIN_COMPLETE.appendIdentifier(userId), isTdeLoginComplete)
}
override fun getShouldTrustDevice(
@@ -373,6 +418,25 @@ class AuthDiskSourceImpl(
getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens)
}
override fun getOnboardingStatus(userId: String): OnboardingStatus? {
return getString(key = ONBOARDING_STATUS_KEY.appendIdentifier(userId))?.let {
json.decodeFromStringOrNull(it)
}
}
override fun storeOnboardingStatus(userId: String, onboardingStatus: OnboardingStatus?) {
putString(
key = ONBOARDING_STATUS_KEY.appendIdentifier(userId),
value = onboardingStatus?.let { json.encodeToString(it) },
)
getMutableOnboardingStatusFlow(userId = userId).tryEmit(onboardingStatus)
}
override fun getOnboardingStatusFlow(userId: String): Flow<OnboardingStatus?> {
return getMutableOnboardingStatusFlow(userId = userId)
.onSubscription { emit(getOnboardingStatus(userId = userId)) }
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
@@ -381,6 +445,20 @@ class AuthDiskSourceImpl(
putString(key = UNIQUE_APP_ID_KEY, value = it)
}
private fun getMutableOnboardingStatusFlow(
userId: String,
): MutableSharedFlow<OnboardingStatus?> =
mutableOnboardingStatusFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShouldUseKeyConnectorFlowMap(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableShouldUseKeyConnectorFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableOrganizationsFlow(
userId: String,
): MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?> =

View File

@@ -37,6 +37,7 @@ data class EnvironmentUrlDataJson(
@SerialName("events")
val events: String? = null,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Default [EnvironmentUrlDataJson] for the US region.

View File

@@ -0,0 +1,41 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Describes the current status of a user in the account onboarding steps.
*/
@Serializable
enum class OnboardingStatus {
/**
* Onboarding has not yet started.
*/
@SerialName("notStarted")
NOT_STARTED,
/**
* The user is completing the account lock setup.
*/
@SerialName("accountLockSetup")
ACCOUNT_LOCK_SETUP,
/**
* The user is completing the auto fill service setup.
*/
@SerialName("autofillSetup")
AUTOFILL_SETUP,
/**
* The user is completing the final step of the onboarding process.
*/
@SerialName("finalStep")
FINAL_STEP,
/**
* The user has completed all onboarding steps.
*/
@SerialName("complete")
COMPLETE,
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysRequest
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson
@@ -53,12 +52,6 @@ interface AuthenticatedAccountsApi {
@HTTP(method = "POST", path = "/accounts/password", hasBody = true)
suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
/**
* Sets the key connector key.
*/
@POST("/accounts/set-key-connector-key")
suspend fun setKeyConnectorKey(@Body body: KeyConnectorKeyRequestJson): Result<Unit>
/**
* Sets the password.
*/

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Defines raw calls specific for key connectors that use custom urls.
*/
@Keep
interface AuthenticatedKeyConnectorApi {
@POST
suspend fun storeMasterKeyToKeyConnector(
@Url url: String,
@Body body: KeyConnectorMasterKeyRequestJson,
): Result<Unit>
}

View File

@@ -1,14 +1,17 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
/**
* Defines raw calls under the /accounts API.
*/
interface AccountsApi {
interface UnauthenticatedAccountsApi {
@POST("/accounts/password-hint")
suspend fun passwordHintRequest(
@Body body: PasswordHintRequestJson,
@@ -18,4 +21,10 @@ interface AccountsApi {
suspend fun resendVerificationCodeEmail(
@Body body: ResendEmailRequestJson,
): Result<Unit>
@POST("/accounts/set-key-connector-key")
suspend fun setKeyConnectorKey(
@Body body: KeyConnectorKeyRequestJson,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
): Result<Unit>
}

View File

@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import kotlinx.serialization.json.JsonPrimitive
import retrofit2.Call
import retrofit2.http.Body
@@ -22,7 +23,7 @@ import retrofit2.http.Query
/**
* Defines raw calls under the /identity API.
*/
interface IdentityApi {
interface UnauthenticatedIdentityApi {
@POST("/connect/token")
@Suppress("LongParameterList")
@@ -79,4 +80,9 @@ interface IdentityApi {
suspend fun sendVerificationEmail(
@Body body: SendVerificationEmailRequestJson,
): Result<JsonPrimitive?>
@POST("/accounts/register/verification-email-clicked")
suspend fun verifyEmailToken(
@Body body: VerifyEmailTokenRequestJson,
): Result<Unit>
}

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Defines raw calls specific for key connectors that use custom urls.
*/
@Keep
interface UnauthenticatedKeyConnectorApi {
@POST
suspend fun storeMasterKeyToKeyConnector(
@Url url: String,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
@Body body: KeyConnectorMasterKeyRequestJson,
): Result<Unit>
@GET
suspend fun getMasterKeyFromKeyConnector(
@Url url: String,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
}

View File

@@ -8,7 +8,7 @@ import retrofit2.http.POST
/**
* Defines raw calls under the /organizations API.
*/
interface OrganizationApi {
interface UnauthenticatedOrganizationApi {
/**
* Checks for the claimed domain organization of an email for SSO purposes.
*/

View File

@@ -36,8 +36,12 @@ object AuthNetworkModule {
retrofits: Retrofits,
json: Json,
): AccountsService = AccountsServiceImpl(
accountsApi = retrofits.unauthenticatedApiRetrofit.create(),
unauthenticatedAccountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedKeyConnectorApi = retrofits.createStaticRetrofit().create(),
authenticatedKeyConnectorApi = retrofits
.createStaticRetrofit(isAuthenticated = true)
.create(),
json = json,
)
@@ -64,7 +68,7 @@ object AuthNetworkModule {
retrofits: Retrofits,
json: Json,
): IdentityService = IdentityServiceImpl(
api = retrofits.unauthenticatedIdentityRetrofit.create(),
unauthenticatedIdentityApi = retrofits.unauthenticatedIdentityRetrofit.create(),
json = json,
)
@@ -93,6 +97,6 @@ object AuthNetworkModule {
retrofits: Retrofits,
): OrganizationService = OrganizationServiceImpl(
authenticatedOrganizationApi = retrofits.authenticatedApiRetrofit.create(),
organizationApi = retrofits.unauthenticatedApiRetrofit.create(),
unauthenticatedOrganizationApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}

View File

@@ -28,6 +28,7 @@ sealed class GetTokenResponseJson {
* this token will be cached and used for future auth requests.
* @property masterPasswordPolicyOptions The options available for a user's master password.
* @property userDecryptionOptions The options available to a user for decryption.
* @property keyConnectorUrl URL to the user's key connector.
*/
@Serializable
data class Success(
@@ -75,6 +76,9 @@ sealed class GetTokenResponseJson {
@SerialName("UserDecryptionOptions")
val userDecryptionOptions: UserDecryptionOptionsJson?,
@SerialName("KeyConnectorUrl")
val keyConnectorUrl: String?,
) : GetTokenResponseJson()
/**

View File

@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to store the master key in the cloud.
*/
@Serializable
data class KeyConnectorMasterKeyRequestJson(
@SerialName("Key") val masterKey: String,
)

View File

@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the response body used to retrieve the master key from the cloud.
*/
@Serializable
data class KeyConnectorMasterKeyResponseJson(
@SerialName("key") val masterKey: String,
)

View File

@@ -1,25 +1,16 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Response object returned when requesting organization domain SSO details.
*
* @property isSsoAvailable Whether or not SSO is available for this domain.
* @property domainName The organization's domain name.
* @property organizationIdentifier The organization's identifier.
* @property isSsoRequired Whether or not SSO is required.
* @property verifiedDate The date these details were verified.
*/
@Serializable
data class OrganizationDomainSsoDetailsResponseJson(
@SerialName("ssoAvailable") val isSsoAvailable: Boolean,
@SerialName("domainName") val domainName: String,
@SerialName("organizationIdentifier") val organizationIdentifier: String,
@SerialName("ssoRequired") val isSsoRequired: Boolean,
@Contextual
@SerialName("verifiedDate") val verifiedDate: ZonedDateTime?,
)

View File

@@ -46,7 +46,6 @@ sealed class RegisterResponseJson {
/**
* Represents the json body of an invalid register request.
*
* @param message
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
@@ -54,18 +53,17 @@ sealed class RegisterResponseJson {
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : RegisterResponseJson()
/**
* A different register error with a message.
*/
@Serializable
data class Error(
@SerialName("Message")
val message: String?,
) : RegisterResponseJson()
) : RegisterResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -22,7 +22,6 @@ sealed class SendVerificationEmailResponseJson {
/**
* Represents the json body of an invalid request.
*
* @param message
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
@@ -30,18 +29,17 @@ sealed class SendVerificationEmailResponseJson {
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : SendVerificationEmailResponseJson()
/**
* A different error with a message.
*/
@Serializable
data class Error(
@SerialName("Message")
val message: String?,
) : SendVerificationEmailResponseJson()
) : SendVerificationEmailResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models the request body for verify email token endpoint.
*
* @param email the email address of the user to verify.
* @param token the provided email verification token.
*/
@Serializable
data class VerifyEmailTokenRequestJson(
@SerialName("email")
val email: String,
@SerialName("emailVerificationToken")
val token: String,
)

View File

@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Model the response of a verify email token request.
*
* A valid response will be a [VerifyEmailTokenResponseJson.Valid]
*
* an invalid response will be a [VerifyEmailTokenResponseJson.Invalid] with a message.
*/
@Serializable
sealed class VerifyEmailTokenResponseJson {
/**
* The token is confirmed as valid from the response.
*/
@Serializable
data object Valid : VerifyEmailTokenResponseJson()
/**
* The response is invalid.
*
* @property message The error message. Expected to explain the reason why the token is invalid.
*/
@Serializable
data class Invalid(
@SerialName("message")
val message: String,
) : VerifyEmailTokenResponseJson()
/**
* The token has expired. This is special case of similar to [Invalid].
*/
@Serializable
data object TokenExpired : VerifyEmailTokenResponseJson()
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
@@ -10,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
/**
* Provides an API for querying accounts endpoints.
*/
@Suppress("TooManyFunctions")
interface AccountsService {
/**
@@ -57,11 +59,48 @@ interface AccountsService {
/**
* Set the key connector key.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun setKeyConnectorKey(body: KeyConnectorKeyRequestJson): Result<Unit>
suspend fun setKeyConnectorKey(
accessToken: String,
body: KeyConnectorKeyRequestJson,
): Result<Unit>
/**
* Set the password.
*/
suspend fun setPassword(body: SetPasswordRequestJson): Result<Unit>
/**
* Retrieves the master key from the key connector.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Stores the master key to the key connector.
*/
suspend fun storeMasterKeyToKeyConnector(
url: String,
masterKey: String,
): Result<Unit>
/**
* Stores the master key to the key connector.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun storeMasterKeyToKeyConnector(
url: String,
accessToken: String,
masterKey: String,
): Result<Unit>
}

View File

@@ -1,11 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedKeyConnectorApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedAccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedKeyConnectorApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysRequest
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
@@ -13,12 +17,19 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordReque
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_BEARER_PREFIX
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import kotlinx.serialization.json.Json
/**
* The default implementation of the [AccountsService].
*/
@Suppress("TooManyFunctions")
class AccountsServiceImpl(
private val accountsApi: AccountsApi,
private val unauthenticatedAccountsApi: UnauthenticatedAccountsApi,
private val authenticatedAccountsApi: AuthenticatedAccountsApi,
private val unauthenticatedKeyConnectorApi: UnauthenticatedKeyConnectorApi,
private val authenticatedKeyConnectorApi: AuthenticatedKeyConnectorApi,
private val json: Json,
) : AccountsService {
@@ -76,7 +87,7 @@ class AccountsServiceImpl(
override suspend fun requestPasswordHint(
email: String,
): Result<PasswordHintResponseJson> =
accountsApi
unauthenticatedAccountsApi
.passwordHintRequest(PasswordHintRequestJson(email))
.map { PasswordHintResponseJson.Success }
.recoverCatching { throwable ->
@@ -90,7 +101,7 @@ class AccountsServiceImpl(
}
override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
accountsApi.resendVerificationCodeEmail(body = body)
unauthenticatedAccountsApi.resendVerificationCodeEmail(body = body)
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> {
return if (body.currentPasswordHash == null) {
@@ -101,10 +112,43 @@ class AccountsServiceImpl(
}
override suspend fun setKeyConnectorKey(
accessToken: String,
body: KeyConnectorKeyRequestJson,
): Result<Unit> = authenticatedAccountsApi.setKeyConnectorKey(body)
): Result<Unit> = unauthenticatedAccountsApi.setKeyConnectorKey(
body = body,
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
)
override suspend fun setPassword(
body: SetPasswordRequestJson,
): Result<Unit> = authenticatedAccountsApi.setPassword(body)
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
unauthenticatedKeyConnectorApi.getMasterKeyFromKeyConnector(
url = "$url/user-keys",
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
)
override suspend fun storeMasterKeyToKeyConnector(
url: String,
masterKey: String,
): Result<Unit> =
authenticatedKeyConnectorApi.storeMasterKeyToKeyConnector(
url = "$url/user-keys",
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
)
override suspend fun storeMasterKeyToKeyConnector(
url: String,
accessToken: String,
masterKey: String,
): Result<Unit> =
unauthenticatedKeyConnectorApi.storeMasterKeyToKeyConnector(
url = "$url/user-keys",
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
)
}

View File

@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
/**
* Provides an API for querying identity endpoints.
@@ -72,4 +74,12 @@ interface IdentityService {
* Register a new account to Bitwarden using email verification flow.
*/
suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
/**
* Makes request to verify email registration token. If the token provided is
* still valid will return success.
*/
suspend fun verifyEmailRegistrationToken(
body: VerifyEmailTokenRequestJson,
): Result<VerifyEmailTokenResponseJson>
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedIdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResult
@@ -20,17 +22,17 @@ import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider
import kotlinx.serialization.json.Json
class IdentityServiceImpl(
private val api: IdentityApi,
private val unauthenticatedIdentityApi: UnauthenticatedIdentityApi,
private val json: Json,
private val deviceModelProvider: DeviceModelProvider = DeviceModelProvider(),
) : IdentityService {
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
api.preLogin(PreLoginRequestJson(email = email))
unauthenticatedIdentityApi.preLogin(PreLoginRequestJson(email = email))
@Suppress("MagicNumber")
override suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson> =
api
unauthenticatedIdentityApi
.register(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
@@ -39,13 +41,8 @@ class IdentityServiceImpl(
code = 400,
json = json,
)
?: bitwardenError
.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
)
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
)
?: throw throwable
@@ -58,7 +55,7 @@ class IdentityServiceImpl(
authModel: IdentityTokenAuthModel,
captchaToken: String?,
twoFactorData: TwoFactorDataModel?,
): Result<GetTokenResponseJson> = api
): Result<GetTokenResponseJson> = unauthenticatedIdentityApi
.getToken(
scope = "api offline_access",
clientId = "mobile",
@@ -94,14 +91,14 @@ class IdentityServiceImpl(
override suspend fun prevalidateSso(
organizationIdentifier: String,
): Result<PrevalidateSsoResponseJson> = api
): Result<PrevalidateSsoResponseJson> = unauthenticatedIdentityApi
.prevalidateSso(
organizationIdentifier = organizationIdentifier,
)
override fun refreshTokenSynchronously(
refreshToken: String,
): Result<RefreshTokenResponseJson> = api
): Result<RefreshTokenResponseJson> = unauthenticatedIdentityApi
.refreshTokenCall(
clientId = "mobile",
grantType = "refresh_token",
@@ -113,7 +110,7 @@ class IdentityServiceImpl(
override suspend fun registerFinish(
body: RegisterFinishRequestJson,
): Result<RegisterResponseJson> =
api
unauthenticatedIdentityApi
.registerFinish(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
@@ -122,18 +119,45 @@ class IdentityServiceImpl(
codes = listOf(400, 429),
json = json,
)
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
json = json,
)
?: throw throwable
}
override suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<String?> {
return api
return unauthenticatedIdentityApi
.sendVerificationEmail(body = body)
.map { it?.content }
}
override suspend fun verifyEmailRegistrationToken(
body: VerifyEmailTokenRequestJson,
): Result<VerifyEmailTokenResponseJson> = unauthenticatedIdentityApi
.verifyEmailToken(
body = body,
)
.map {
VerifyEmailTokenResponseJson.Valid
}
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError
.parseErrorBodyOrNull<VerifyEmailTokenResponseJson.Invalid>(
code = 400,
json = json,
)
?.checkForExpiredMessage()
?: throw throwable
}
}
/**
* If the message body contains text related to the token being expired, return
* the TokenExpired type. Otherwise, return the original Invalid response.
*/
private fun VerifyEmailTokenResponseJson.Invalid.checkForExpiredMessage() =
if (message.contains(other = "expired", ignoreCase = true)) {
VerifyEmailTokenResponseJson.TokenExpired
} else {
this
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedOrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedOrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
@@ -13,7 +13,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationResetP
*/
class OrganizationServiceImpl(
private val authenticatedOrganizationApi: AuthenticatedOrganizationApi,
private val organizationApi: OrganizationApi,
private val unauthenticatedOrganizationApi: UnauthenticatedOrganizationApi,
) : OrganizationService {
override suspend fun organizationResetPasswordEnroll(
organizationId: String,
@@ -32,7 +32,7 @@ class OrganizationServiceImpl(
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): Result<OrganizationDomainSsoDetailsResponseJson> = organizationApi
): Result<OrganizationDomainSsoDetailsResponseJson> = unauthenticatedOrganizationApi
.getClaimedDomainOrganizationDetails(
body = OrganizationDomainSsoDetailsRequestJson(
email = email,

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
@@ -37,6 +38,11 @@ interface AuthSdkSource {
purpose: HashPurpose,
): Result<String>
/**
* Creates a set of encryption key information for use with a key connector.
*/
suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse>
/**
* Creates a set of encryption key information for registration.
*/

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
@@ -63,6 +64,13 @@ class AuthSdkSourceImpl(
)
}
override suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse> =
runCatchingWithLogs {
getClient()
.auth()
.makeKeyConnectorKeys()
}
override suspend fun makeRegisterKeys(
email: String,
password: String,

View File

@@ -10,7 +10,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
import kotlinx.coroutines.flow.Flow
/**
* A manager class for handling authentication fo logging in with remote device.
* A manager class for handling authentication for logging in with remote device.
*/
interface AuthRequestManager {
/**

View File

@@ -0,0 +1,46 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
/**
* Manager used to interface with a key connector.
*/
interface KeyConnectorManager {
/**
* Retrieves the master key from the key connector.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Migrates an existing user to use the key connector.
*/
@Suppress("LongParameterList")
suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
userKeyEncrypted: String,
email: String,
masterPassword: String,
kdf: Kdf,
): Result<Unit>
/**
* Migrates a new user to use the key connector.
*/
@Suppress("LongParameterList")
suspend fun migrateNewUserToKeyConnector(
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse>
}

View File

@@ -0,0 +1,88 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
/**
* The default implementation of the [KeyConnectorManager].
*/
class KeyConnectorManagerImpl(
private val accountsService: AccountsService,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
accountsService.getMasterKeyFromKeyConnector(
url = url,
accessToken = accessToken,
)
override suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
userKeyEncrypted: String,
email: String,
masterPassword: String,
kdf: Kdf,
): Result<Unit> =
vaultSdkSource
.deriveKeyConnector(
userId = userId,
userKeyEncrypted = userKeyEncrypted,
email = email,
password = masterPassword,
kdf = kdf,
)
.flatMap { masterKey ->
accountsService.storeMasterKeyToKeyConnector(url = url, masterKey = masterKey)
}
.flatMap { accountsService.convertToKeyConnector() }
override suspend fun migrateNewUserToKeyConnector(
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse> =
authSdkSource
.makeKeyConnectorKeys()
.flatMap { keyConnectorResponse ->
accountsService
.storeMasterKeyToKeyConnector(
url = url,
accessToken = accessToken,
masterKey = keyConnectorResponse.masterKey,
)
.flatMap {
accountsService.setKeyConnectorKey(
accessToken = accessToken,
body = KeyConnectorKeyRequestJson(
userKey = keyConnectorResponse.encryptedUserKey,
keys = KeyConnectorKeyRequestJson.Keys(
publicKey = keyConnectorResponse.keys.public,
encryptedPrivateKey = keyConnectorResponse.keys.private,
),
kdfType = kdfType,
kdfIterations = kdfIterations,
kdfMemory = kdfMemory,
kdfParallelism = kdfParallelism,
organizationIdentifier = organizationIdentifier,
),
)
}
.map { keyConnectorResponse }
}
}

View File

@@ -18,6 +18,7 @@ class TrustedDeviceManagerImpl(
) : TrustedDeviceManager {
override suspend fun trustThisDeviceIfNecessary(userId: String): Result<Boolean> =
if (authDiskSource.getShouldTrustDevice(userId = userId) != true) {
authDiskSource.storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = true)
false.asSuccess()
} else {
vaultSdkSource
@@ -51,6 +52,7 @@ class TrustedDeviceManagerImpl(
userId = userId,
previousUserState = requireNotNull(authDiskSource.userState),
)
authDiskSource.storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = true)
}
.also { authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null) }
.map { Unit }

View File

@@ -14,15 +14,15 @@ interface UserLogoutManager {
val logoutEventFlow: SharedFlow<LogoutEvent>
/**
* Completely logs out the given [userId], removing all data.
* If [isExpired] is true, a toast will be displayed
* letting the user know the session has expired.
* Completely logs out the given [userId], removing all data. If [isExpired] is true, a toast
* will be displayed letting the user know the session has expired.
*/
fun logout(userId: String, isExpired: Boolean = false)
/**
* Partially logs out the given [userId]. All data for the given [userId] will be removed with
* the exception of basic account data.
* the exception of basic account data. If [isExpired] is true, a toast will be displayed
* letting the user know the session has expired.
*/
fun softLogout(userId: String)
fun softLogout(userId: String, isExpired: Boolean = false)
}

View File

@@ -64,7 +64,10 @@ class UserLogoutManagerImpl(
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
}
override fun softLogout(userId: String) {
override fun softLogout(userId: String, isExpired: Boolean) {
if (isExpired) {
showToast(message = R.string.login_expired)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = null,
@@ -74,7 +77,11 @@ class UserLogoutManagerImpl(
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
switchUserIfAvailable(currentUserId = userId, removeCurrentUserFromAccounts = false)
switchUserIfAvailable(
currentUserId = userId,
removeCurrentUserFromAccounts = false,
isExpired = isExpired,
)
clearData(userId = userId)
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.di
import android.content.Context
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
@@ -10,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManagerImpl
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
@@ -71,6 +74,19 @@ object AuthManagerModule {
authDiskSource = authDiskSource,
)
@Provides
@Singleton
fun provideKeyConnectorManager(
accountsService: AccountsService,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
)
@Provides
@Singleton
fun provideTrustedDeviceManager(

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.auth.repository
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.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
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
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@@ -16,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
@@ -102,6 +105,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
var rememberedOrgIdentifier: String?
/**
* The currently persisted state indicating whether the user has completed login via TDE.
*/
val tdeLoginComplete: Boolean?
/**
* The currently persisted state indicating whether the user has trusted this device.
*/
@@ -267,6 +275,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
): PasswordHintResult
/**
* Removes the users password from the account. This used used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
/**
* Resets the users password from the [currentPassword] (or null for account recovery resets),
* to the [newPassword] and optional [passwordHint].
@@ -365,4 +379,17 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult
/**
* Validates the given [token] for the given [email]. Part of th new account registration flow.
*/
suspend fun validateEmailToken(
email: String,
token: String,
): EmailTokenResult
/**
* Update the value of the onboarding status for the user.
*/
fun setOnboardingStatus(userId: String, status: OnboardingStatus?)
}

View File

@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
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.DeleteAccountResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
@@ -26,6 +27,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@@ -35,11 +38,13 @@ 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.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
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
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@@ -49,6 +54,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
@@ -56,6 +62,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResul
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.ValidatePasswordResult
@@ -68,12 +75,17 @@ 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
@@ -92,6 +104,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -142,6 +155,7 @@ class AuthRepositoryImpl(
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRequestManager: AuthRequestManager,
private val keyConnectorManager: KeyConnectorManager,
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
@@ -238,6 +252,8 @@ class AuthRepositoryImpl(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
@@ -249,13 +265,17 @@ class AuthRepositoryImpl(
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val vaultState = array[3] as List<VaultUnlockData>
val hasPendingAccountAddition = array[4] as Boolean
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val vaultState = array[5] as List<VaultUnlockData>
val hasPendingAccountAddition = array[6] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
@@ -272,7 +292,9 @@ class AuthRepositoryImpl(
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,
@@ -300,6 +322,9 @@ class AuthRepositoryImpl(
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
override val tdeLoginComplete: Boolean?
get() = activeUserId?.let { authDiskSource.getIsTdeLoginComplete(userId = it) }
override var shouldTrustDevice: Boolean
get() = activeUserId?.let { authDiskSource.getShouldTrustDevice(userId = it) } ?: false
set(value) {
@@ -538,8 +563,7 @@ class AuthRepositoryImpl(
),
)
}
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
settingsRepository.storeUserHasLoggedInValue(userId)
vaultRepository.syncIfNecessary()
return LoginResult.Success
}
@@ -757,6 +781,7 @@ class AuthRepositoryImpl(
)
.flatMap { registerKeyResponse ->
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,
@@ -815,10 +840,6 @@ class AuthRepositoryImpl(
?: it.message,
)
}
is RegisterResponseJson.Error -> {
RegisterResult.Error(it.message)
}
}
},
onFailure = { RegisterResult.Error(errorMessage = null) },
@@ -837,6 +858,46 @@ class AuthRepositoryImpl(
)
}
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return RemovePasswordResult.Error
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
?: return RemovePasswordResult.Error
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error
return keyConnectorManager
.migrateExistingUserToKeyConnector(
userId = userId,
url = keyConnectorUrl,
userKeyEncrypted = userKey,
email = profile.email,
masterPassword = masterPassword,
kdf = profile.toSdkParams(),
)
.onSuccess {
authDiskSource.userState = authDiskSource
.userState
?.toRemovedPasswordUserStateJson(userId = userId)
vaultRepository.sync()
settingsRepository.setDefaultsIfNecessary(userId = userId)
}
.fold(
onFailure = { RemovePasswordResult.Error },
onSuccess = { RemovePasswordResult.Success },
)
}
override suspend fun resetPassword(
currentPassword: String?,
newPassword: String,
@@ -1205,6 +1266,35 @@ class AuthRepositoryImpl(
},
)
override suspend fun validateEmailToken(email: String, token: String): EmailTokenResult {
return identityService
.verifyEmailRegistrationToken(
body = VerifyEmailTokenRequestJson(
email = email,
token = token,
),
)
.fold(
onSuccess = {
when (val json = it) {
VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
is VerifyEmailTokenResponseJson.Invalid -> {
EmailTokenResult.Error(json.message)
}
VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
}
},
onFailure = {
EmailTokenResult.Error(message = null)
},
)
}
override fun setOnboardingStatus(userId: String, status: OnboardingStatus?) {
authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
@@ -1413,30 +1503,42 @@ class AuthRepositoryImpl(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
)
val userId = userStateJson.activeUserId
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
val keyConnectorUrl = loginResponse
.keyConnectorUrl
?: loginResponse
.userDecryptionOptions
?.keyConnectorUserDecryptionOptions
?.keyConnectorUrl
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
if (isDeviceUnlockAvailable) {
unlockVaultWithTdeOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
profile = profile,
deviceData = deviceData,
)
} else if (keyConnectorUrl != null && orgIdentifier != null) {
unlockVaultWithKeyConnectorOnLoginSuccess(
profile = profile,
keyConnectorUrl = keyConnectorUrl,
orgIdentifier = orgIdentifier,
loginResponse = loginResponse,
)
} else {
password?.let {
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
password = it,
)
}
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
profile = profile,
password = password,
)
}
}
@@ -1446,7 +1548,7 @@ class AuthRepositoryImpl(
.hashPassword(
email = email,
password = it,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
kdf = profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
@@ -1468,14 +1570,27 @@ class AuthRepositoryImpl(
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true
val shouldSetOnboardingStatus = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow) &&
!settingsRepository.getUserHasLoggedInValue(userId = userId)
if (shouldSetOnboardingStatus) {
setOnboardingStatus(
userId = userId,
status = OnboardingStatus.NOT_STARTED,
)
}
authDiskSource.userState = userStateJson
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.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
authDiskSource.storePrivateKey(userId = userId, privateKey = loginResponse.privateKey)
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
// of the two-factor code on the next login attempt.
@@ -1494,6 +1609,7 @@ class AuthRepositoryImpl(
resendEmailRequestJson = null
twoFactorDeviceData = null
settingsRepository.setDefaultsIfNecessary(userId = userId)
settingsRepository.storeUserHasLoggedInValue(userId)
vaultRepository.syncIfNecessary()
hasPendingAccountAddition = false
LoginResult.Success
@@ -1524,12 +1640,89 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired
}
/**
* Attempt to unlock the current user's vault with key connector data.
*/
private suspend fun unlockVaultWithKeyConnectorOnLoginSuccess(
profile: AccountJson.Profile,
keyConnectorUrl: String,
orgIdentifier: String,
loginResponse: GetTokenResponseJson.Success,
): VaultUnlockResult? =
if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
// This user has a master password, so we skip the key-connector logic as it is not
// setup yet. The user can still unlock the vault with their master password.
null
} else if (loginResponse.key != null && loginResponse.privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = loginResponse.key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
} else {
// This is a new user who needs to setup the key connector
keyConnectorManager
.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
kdfIterations = loginResponse.kdfIterations,
kdfMemory = loginResponse.kdfMemory,
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val result = unlockVault(
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
}
result
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
}
/**
* Attempt to unlock the current user's vault with password data.
*/
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
password: String?,
): VaultUnlockResult? {
// Attempt to unlock the vault with password if possible.
@@ -1537,7 +1730,7 @@ class AuthRepositoryImpl(
val privateKey = loginResponse.privateKey ?: return null
val key = loginResponse.key ?: return null
return unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
@@ -1551,7 +1744,7 @@ class AuthRepositoryImpl(
*/
private suspend fun unlockVaultWithTdeOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
deviceData: DeviceDataModel?,
): VaultUnlockResult? {
// Attempt to unlock the vault with auth request if possible.
@@ -1559,7 +1752,7 @@ class AuthRepositoryImpl(
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
return unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
@@ -1588,7 +1781,7 @@ class AuthRepositoryImpl(
loginResponse.privateKey?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
userStateJson = userStateJson,
profile = profile,
privateKey = privateKey,
)
}
@@ -1601,11 +1794,11 @@ class AuthRepositoryImpl(
*/
private suspend fun unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options: TrustedDeviceUserDecryptionOptionsJson,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
privateKey: String,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = userStateJson.activeUserId
val userId = profile.userId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
@@ -1619,7 +1812,7 @@ class AuthRepositoryImpl(
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
@@ -1646,7 +1839,7 @@ class AuthRepositoryImpl(
}
vaultUnlockResult = unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,

View File

@@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
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.repository.AuthRepository
@@ -48,6 +49,7 @@ object AuthRepositoryModule {
environmentRepository: EnvironmentRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
keyConnectorManager: KeyConnectorManager,
authRequestManager: AuthRequestManager,
trustedDeviceManager: TrustedDeviceManager,
userLogoutManager: UserLogoutManager,
@@ -67,6 +69,7 @@ object AuthRepositoryModule {
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
keyConnectorManager = keyConnectorManager,
authRequestManager = authRequestManager,
trustedDeviceManager = trustedDeviceManager,
userLogoutManager = userLogoutManager,

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Model the result of a request to validate a given email token.
*/
sealed class EmailTokenResult {
/**
* The token is valid and the user can proceed with account creation.
*/
data object Success : EmailTokenResult()
/**
* The token has expired and is no longer valid.
*/
data object Expired : EmailTokenResult()
/**
* There was an error validating the token.
*/
data class Error(val message: String?) : EmailTokenResult()
}

View File

@@ -37,4 +37,10 @@ data class JwtTokenDataJson(
@SerialName("amr")
val authenticationMethodsReference: List<String>,
)
) {
/**
* Indicates that this is an external user. Mainly used for SSO users with a key connector.
*/
val isExternal: Boolean
get() = authenticationMethodsReference.any { it == "external" }
}

View File

@@ -1,12 +1,21 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
/**
* Represents an organization a user may be a member of.
*
* @property id The ID of the organization.
* @property name The name of the organization (if applicable).
* @property shouldManageResetPassword Indicates that this user has the permission to manage their
* own password.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
*/
data class Organization(
val id: String,
val name: String?,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
)

View File

@@ -47,7 +47,7 @@ sealed class PolicyInformation {
/**
* Represents a policy enforcing rules on the password generator.
*
* @property defaultType The default type of password to be generated.
* @property overridePasswordType The default type of password to be generated.
* @property minLength The minimum length of the password.
* @property useUpper Whether the password requires upper case letters.
* @property useLower Whether the password requires lower case letters.
@@ -61,8 +61,8 @@ sealed class PolicyInformation {
*/
@Serializable
data class PasswordGenerator(
@SerialName("defaultType")
val defaultType: String?,
@SerialName("overridePasswordType")
val overridePasswordType: String?,
@SerialName("minLength")
val minLength: Int?,
@@ -94,6 +94,7 @@ sealed class PolicyInformation {
@SerialName("includeNumber")
val includeNumber: Boolean?,
) : PolicyInformation() {
@Suppress("UndocumentedPublicClass")
companion object {
const val TYPE_PASSWORD: String = "password"
const val TYPE_PASSPHRASE: String = "passphrase"

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of removing a user's password.
*/
sealed class RemovePasswordResult {
/**
* The password was removed successfully.
*/
data object Success : RemovePasswordResult()
/**
* There was an error removing the password.
*/
data object Error : RemovePasswordResult()
}

View File

@@ -0,0 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Associates [isUsingKeyConnector] with the given [userId].
*/
data class UserKeyConnectorState(
val userId: String,
val isUsingKeyConnector: Boolean?,
)

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserState.Account
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@@ -45,10 +46,12 @@ data class UserState(
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
* determine whether a user has a master password. There are cases in which a user can both
* not have a password but still not need one, such as TDE.
* @property hasMasterPassword Indicates that the user does or does not have a master password.
* @property organizations List of [Organization]s the user is associated with, if any.
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
* user's vault is enabled.
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
*/
data class Account(
val userId: String,
@@ -61,16 +64,14 @@ data class UserState(
val isVaultUnlocked: Boolean,
val needsPasswordReset: Boolean,
val needsMasterPassword: Boolean,
val hasMasterPassword: Boolean,
val trustedDevice: TrustedDevice?,
val organizations: List<Organization>,
val isBiometricsEnabled: Boolean,
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
val isUsingKeyConnector: Boolean,
val onboardingStatus: OnboardingStatus,
) {
/**
* Indicates that the user does or does not have a master password.
*/
val hasMasterPassword: Boolean get() = trustedDevice?.hasMasterPassword != false
/**
* Indicates that the user does or does not have a means to manually unlock the vault.
*/
@@ -86,7 +87,6 @@ data class UserState(
*/
data class TrustedDevice(
val isDeviceTrusted: Boolean,
val hasMasterPassword: Boolean,
val hasAdminApproval: Boolean,
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,

View File

@@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.util
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.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.UserSwitchingData
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
/**
@@ -100,6 +103,47 @@ val AuthDiskSource.userAccountTokensFlow: Flow<List<UserAccountTokens>>
}
.distinctUntilChanged()
/**
* Returns the current list of [UserKeyConnectorState].
*/
val AuthDiskSource.userKeyConnectorStateList: List<UserKeyConnectorState>
get() = this
.userState
?.accounts
.orEmpty()
.map { (userId, _) ->
UserKeyConnectorState(
userId = userId,
isUsingKeyConnector = this.getShouldUseKeyConnector(userId = userId),
)
}
/**
* Returns a [Flow] that emits distinct updates to [UserKeyConnectorState].
*/
@OptIn(ExperimentalCoroutinesApi::class)
val AuthDiskSource.userKeyConnectorStateFlow: Flow<List<UserKeyConnectorState>>
get() = this
.userStateFlow
.flatMapLatest { userStateJson ->
combine(
userStateJson
?.accounts
.orEmpty()
.map { (userId, _) ->
this
.getShouldUseKeyConnectorFlow(userId = userId)
.map {
UserKeyConnectorState(
userId = userId,
isUsingKeyConnector = it,
)
}
},
) { it.toList() }
}
.distinctUntilChanged()
/**
* Returns a [Flow] that emits every time the active user is changed.
*/
@@ -125,3 +169,22 @@ val AuthDiskSource.activeUserIdChangesFlow: Flow<String?>
.userStateFlow
.map { it?.activeUserId }
.distinctUntilChanged()
/**
* Returns a [Flow] that emits every time the active user's onboarding status is changed
*/
@OptIn(ExperimentalCoroutinesApi::class)
val AuthDiskSource.onboardingStatusChangesFlow: Flow<OnboardingStatus?>
get() = activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
activeUserId
?.let { this.getOnboardingStatusFlow(userId = it) }
?: flowOf(null)
}
.distinctUntilChanged()
val AuthDiskSource.currentOnboardingStatus: OnboardingStatus?
get() = this
.userState
?.activeUserId
?.let { this.getOnboardingStatus(userId = it) }

View File

@@ -7,6 +7,11 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.serialization.json.Json
private val JSON = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
/**
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
*/
@@ -14,6 +19,9 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
Organization(
id = this.id,
name = this.name,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
)
/**
@@ -28,21 +36,22 @@ fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organiza
*/
val SyncResponseJson.Policy.policyInformation: PolicyInformation?
get() = data?.toString()?.let {
when (type) {
PolicyTypeJson.MASTER_PASSWORD -> {
Json.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
JSON.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
Json.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
JSON.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
Json.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
JSON.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
PolicyTypeJson.SEND_OPTIONS -> {
Json.decodeFromStringOrNull<PolicyInformation.SendOptions>(it)
JSON.decodeFromStringOrNull<PolicyInformation.SendOptions>(it)
}
else -> null

View File

@@ -1,17 +1,46 @@
package com.x8bit.bitwarden.data.auth.repository.util
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.UserDecryptionOptionsJson
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.platform.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.ui.platform.base.util.toHexColorRepresentation
/**
* Updates the given [UserStateJson] with the data to indicate that the password has been removed.
* The original will be returned if the [userId] does not match any accounts in the [UserStateJson].
*/
fun UserStateJson.toRemovedPasswordUserStateJson(
userId: String,
): UserStateJson {
val account = this.accounts[userId] ?: return this
val profile = account.profile
val updatedUserDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = false)
?: UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
)
val updatedProfile = profile.copy(userDecryptionOptions = updatedUserDecryptionOptions)
val updatedAccount = account.copy(profile = updatedProfile)
return this.copy(
accounts = accounts
.toMutableMap()
.apply { replace(userId, updatedAccount) },
)
}
/**
* Updates the given [UserStateJson] with the data from the [syncResponse] to return a new
* [UserStateJson]. The original will be returned if the sync response does not match any accounts
@@ -74,12 +103,14 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
fun UserStateJson.toUserState(
vaultState: List<VaultUnlockData>,
userAccountTokens: List<UserAccountTokens>,
userOrganizationsList: List<UserOrganizations>,
userIsUsingKeyConnectorList: List<UserKeyConnectorState>,
hasPendingAccountAddition: Boolean,
onboardingStatus: OnboardingStatus?,
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
@@ -97,13 +128,21 @@ fun UserStateJson.toUserState(
val decryptionOptions = profile.userDecryptionOptions
val trustedDeviceOptions = decryptionOptions?.trustedDeviceUserDecryptionOptions
val keyConnectorOptions = decryptionOptions?.keyConnectorUserDecryptionOptions
val organizations = userOrganizationsList
.find { it.userId == userId }
?.organizations
.orEmpty()
val hasManageResetPasswordPermission = organizations.any {
it.role == OrganizationType.OWNER ||
it.role == OrganizationType.ADMIN ||
it.shouldManageResetPassword
}
val needsMasterPassword = decryptionOptions?.hasMasterPassword == false &&
trustedDeviceOptions?.hasManageResetPasswordPermission != false &&
hasManageResetPasswordPermission &&
keyConnectorOptions == null
val trustedDevice = trustedDeviceOptions?.let {
UserState.TrustedDevice(
isDeviceTrusted = isDeviceTrustedProvider(userId),
hasMasterPassword = decryptionOptions.hasMasterPassword,
hasAdminApproval = it.hasAdminApproval,
hasLoginApprovingDevice = it.hasLoginApprovingDevice,
hasResetPasswordPermission = it.hasManageResetPasswordPermission,
@@ -125,14 +164,18 @@ fun UserStateJson.toUserState(
?.isLoggedIn == true,
isVaultUnlocked = vaultUnlocked,
needsPasswordReset = needsPasswordReset,
organizations = userOrganizationsList
.find { it.userId == userId }
?.organizations
.orEmpty(),
organizations = organizations,
isBiometricsEnabled = isBiometricsEnabledProvider(userId),
vaultUnlockType = vaultUnlockTypeProvider(userId),
needsMasterPassword = needsMasterPassword,
hasMasterPassword = decryptionOptions?.hasMasterPassword != false,
trustedDevice = trustedDevice,
isUsingKeyConnector = userIsUsingKeyConnectorList
.find { it.userId == userId }
?.isUsingKeyConnector == true,
// If the user exists with no onboarding status we can assume they have been
// using the app prior to the release of the onboarding flow.
onboardingStatus = onboardingStatus ?: OnboardingStatus.COMPLETE,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,

View File

@@ -9,7 +9,11 @@ import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
* The [CompleteRegistrationData] will be returned when present.
*/
fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
val sanitizedUriString = data.toString().replace("/#/", "/")
val sanitizedUriString = data.toString().replace(
oldValue = "/redirect-connector.html#",
newValue = "/",
ignoreCase = true,
)
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
uri.host ?: return null
if (uri.path != "/finish-signup") return null

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.autofill.accessibility
import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessor
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.tiles.BitwardenAutofillTileService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* The [AccessibilityService] implementation for the app. This is not used in the traditional
* way, we use the [BitwardenAutofillTileService] to invoke this service in order to provide an
* autofill fallback mechanism.
*/
@Keep
@OmitFromCoverage
@AndroidEntryPoint
class BitwardenAccessibilityService : AccessibilityService() {
@Inject
lateinit var processor: BitwardenAccessibilityProcessor
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (rootInActiveWindow?.packageName != event.packageName) return
processor.processAccessibilityEvent(rootAccessibilityNodeInfo = rootInActiveWindow)
}
override fun onInterrupt() = Unit
}

View File

@@ -0,0 +1,118 @@
package com.x8bit.bitwarden.data.autofill.accessibility.di
import android.content.Context
import android.content.pm.PackageManager
import android.os.PowerManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParserImpl
import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessor
import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessorImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
* Provides dependencies within the accessibility package.
*/
@Module
@InstallIn(SingletonComponent::class)
object AccessibilityModule {
@Singleton
@Provides
fun providesAccessibilityCompletionManager(
accessibilityAutofillManager: AccessibilityAutofillManager,
totpManager: AutofillTotpManager,
dispatcherManager: DispatcherManager,
): AccessibilityCompletionManager =
AccessibilityCompletionManagerImpl(
accessibilityAutofillManager = accessibilityAutofillManager,
totpManager = totpManager,
dispatcherManager = dispatcherManager,
)
@Singleton
@Provides
fun providesAccessibilityAutofillManager(): AccessibilityAutofillManager =
AccessibilityAutofillManagerImpl()
@Singleton
@Provides
fun providesAccessibilityEnabledManager(): AccessibilityEnabledManager =
AccessibilityEnabledManagerImpl()
@Singleton
@Provides
fun providesAccessibilityNodeInfoManager(): AccessibilityNodeInfoManager =
AccessibilityNodeInfoManagerImpl()
@Singleton
@Provides
fun providesAccessibilityParser(
accessibilityNodeInfoManager: AccessibilityNodeInfoManager,
): AccessibilityParser = AccessibilityParserImpl(
accessibilityNodeInfoManager = accessibilityNodeInfoManager,
)
@Singleton
@Provides
fun providesAccessibilitySelectionManager(): AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
@Singleton
@Provides
fun providesBitwardenAccessibilityProcessor(
@ApplicationContext context: Context,
accessibilityParser: AccessibilityParser,
accessibilityAutofillManager: AccessibilityAutofillManager,
launcherPackageNameManager: LauncherPackageNameManager,
powerManager: PowerManager,
): BitwardenAccessibilityProcessor =
BitwardenAccessibilityProcessorImpl(
context = context,
accessibilityParser = accessibilityParser,
accessibilityAutofillManager = accessibilityAutofillManager,
launcherPackageNameManager = launcherPackageNameManager,
powerManager = powerManager,
)
@Singleton
@Provides
fun providesLauncherPackageNameManager(
clock: Clock,
packageManager: PackageManager,
): LauncherPackageNameManager =
LauncherPackageNameManagerImpl(
clockProvider = { clock },
packageManager = packageManager,
)
@Singleton
@Provides
fun providesPackageManager(
@ApplicationContext context: Context,
): PackageManager = context.packageManager
@Singleton
@Provides
fun providesPowerManager(
@ApplicationContext context: Context,
): PowerManager = context.getSystemService(PowerManager::class.java)
}

View File

@@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.autofill.accessibility.di
import android.content.Context
import androidx.lifecycle.LifecycleCoroutineScope
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityScoped
/**
* Provides dependencies within the accessibility package scoped to the activity.
*/
@Module
@InstallIn(ActivityComponent::class)
object ActivityAccessibilityModule {
@ActivityScoped
@Provides
fun providesAccessibilityActivityManager(
@ApplicationContext context: Context,
accessibilityEnabledManager: AccessibilityEnabledManager,
appForegroundManager: AppForegroundManager,
lifecycleScope: LifecycleCoroutineScope,
): AccessibilityActivityManager =
AccessibilityActivityManagerImpl(
context = context,
accessibilityEnabledManager = accessibilityEnabledManager,
appForegroundManager = appForegroundManager,
lifecycleScope = lifecycleScope,
)
}

View File

@@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
/**
* A helper for dealing with accessibility configuration that must be scoped to a specific
* [Activity]. In particular, this should be injected into an [Activity] to ensure that the
* [AccessibilityEnabledManager] reports correct values.
*/
interface AccessibilityActivityManager

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.content.Context
import androidx.lifecycle.LifecycleCoroutineScope
import com.x8bit.bitwarden.data.autofill.accessibility.util.isAccessibilityServiceEnabled
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* The default implementation of the [AccessibilityActivityManager].
*/
class AccessibilityActivityManagerImpl(
private val context: Context,
private val accessibilityEnabledManager: AccessibilityEnabledManager,
appForegroundManager: AppForegroundManager,
lifecycleScope: LifecycleCoroutineScope,
) : AccessibilityActivityManager {
init {
appForegroundManager
.appForegroundStateFlow
.onEach {
accessibilityEnabledManager.isAccessibilityEnabled =
context.isAccessibilityServiceEnabled
}
.launchIn(lifecycleScope)
}
}

View File

@@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
/**
* A relay manager used to notify the accessibility service to attempt an autofill.
*/
interface AccessibilityAutofillManager {
/**
* Indicates that the Autofill tile has been clicked and we attempt an accessibility-based
* autofill.
*/
var accessibilityAction: AccessibilityAction?
}

View File

@@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
/**
* The default implementation for the [AccessibilityAutofillManager].
*/
class AccessibilityAutofillManagerImpl : AccessibilityAutofillManager {
override var accessibilityAction: AccessibilityAction? = null
}

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
import com.bitwarden.vault.CipherView
/**
* A manager for completing the accessibility-based autofill process after the user has made a
* selection.
*/
interface AccessibilityCompletionManager {
/**
* Completes the accessibility-based autofill flow originating with the given [activity] using
* the selected [cipherView].
*/
fun completeAccessibilityAutofill(activity: Activity, cipherView: CipherView)
}

View File

@@ -0,0 +1,53 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.autofill.accessibility.util.toUriOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Default implementation for the [AccessibilityCompletionManager].
*/
class AccessibilityCompletionManagerImpl(
private val accessibilityAutofillManager: AccessibilityAutofillManager,
private val totpManager: AutofillTotpManager,
dispatcherManager: DispatcherManager,
) : AccessibilityCompletionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
override fun completeAccessibilityAutofill(activity: Activity, cipherView: CipherView) {
val autofillSelectionData = activity
.intent
?.getAutofillSelectionDataOrNull()
?: run {
activity.finish()
return
}
if (autofillSelectionData.framework != AutofillSelectionData.Framework.ACCESSIBILITY) {
activity.finish()
return
}
val uri = autofillSelectionData
.uri
?.toUriOrNull()
?: run {
activity.finish()
return
}
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptFill(
cipherView = cipherView,
uri = uri,
)
mainScope.launch {
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
activity.finish()
}
}
}

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import kotlinx.coroutines.flow.StateFlow
/**
* A container for values specifying whether or not the accessibility service is enabled.
*/
interface AccessibilityEnabledManager {
/**
* Whether or not the accessibility service should be considered enabled.
*
* Note that changing this does not enable or disable autofill; it is only an indicator that
* this has occurred elsewhere.
*/
var isAccessibilityEnabled: Boolean
/**
* Emits updates that track [isAccessibilityEnabled] values.
*/
val isAccessibilityEnabledStateFlow: StateFlow<Boolean>
}

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* The default implementation of [AccessibilityEnabledManager].
*/
class AccessibilityEnabledManagerImpl : AccessibilityEnabledManager {
private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(value = false)
override var isAccessibilityEnabled: Boolean
get() = mutableIsAccessibilityEnabledStateFlow.value
set(value) {
mutableIsAccessibilityEnabledStateFlow.value = value
}
override val isAccessibilityEnabledStateFlow: StateFlow<Boolean>
get() = mutableIsAccessibilityEnabledStateFlow.asStateFlow()
}

View File

@@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
/**
* The default maximum recursive depth that the
* [AccessibilityNodeInfoManager.findAccessibilityNodeInfoList] will go.
*/
const val DEFAULT_MAX_RECURSION_DEPTH: Int = 100
/**
* A manager for finding fields that match particular characteristics.
*/
interface AccessibilityNodeInfoManager {
/**
* A helper function for retrieving the appropriate nodes based on the given [predicate].
*
* This function is recursive but will stop recurring if the depth it reaches is greater than
* the [maxRecursionDepth].
*/
fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int = DEFAULT_MAX_RECURSION_DEPTH,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo>
/**
* Determines which [AccessibilityNodeInfo] is a username field.
*/
fun findUsernameAccessibilityNodeInfo(
uri: Uri,
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo?
}

View File

@@ -0,0 +1,96 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.net.Uri
import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.autofill.accessibility.util.getKnownUsernameFieldNull
import com.x8bit.bitwarden.data.autofill.accessibility.util.isUsername
private const val MAX_NODE_COUNT: Int = 100
/**
* The default implementation for the [AccessibilityNodeInfoManager].
*/
class AccessibilityNodeInfoManagerImpl : AccessibilityNodeInfoManager {
override fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo> =
findAccessibilityNodeInfoList(
rootNode = rootNode,
maxRecursionDepth = maxRecursionDepth,
currentRecursionDepth = 0,
predicate = predicate,
)
override fun findUsernameAccessibilityNodeInfo(
uri: Uri,
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo? {
val uriPath = uri
.path
?: return findMissingUsernameNodeInfo(
allNodes = allNodes,
passwordNodes = passwordNodes,
)
return uri
.authority
?.removePrefix(prefix = "www.")
?.getKnownUsernameFieldNull()
?.let { usernameField ->
allNodes.firstOrNull { node ->
node.isUsername(
uriPath = uriPath,
knownUsernameField = usernameField,
)
}
}
?: findMissingUsernameNodeInfo(allNodes = allNodes, passwordNodes = passwordNodes)
}
private fun findAccessibilityNodeInfoList(
rootNode: AccessibilityNodeInfo,
maxRecursionDepth: Int,
currentRecursionDepth: Int,
predicate: (AccessibilityNodeInfo) -> Boolean,
): List<AccessibilityNodeInfo> {
if (predicate(rootNode)) return listOf(rootNode)
if (currentRecursionDepth >= maxRecursionDepth) return emptyList()
val childNodeCount = rootNode.childCount - 1
if (childNodeCount > MAX_NODE_COUNT) log(message = "Too many child iterations.")
return (0..childNodeCount.coerceAtMost(maximumValue = MAX_NODE_COUNT)).flatMap {
val childNode = rootNode.getChild(it) ?: return@flatMap emptyList()
if (childNode.hashCode() == this.hashCode()) {
log(message = "Child node is the same as parent for some reason.")
emptyList()
} else {
findAccessibilityNodeInfoList(
rootNode = childNode,
maxRecursionDepth = maxRecursionDepth,
currentRecursionDepth = currentRecursionDepth + 1,
predicate = predicate,
)
}
}
}
/**
* Attempts to find a username [AccessibilityNodeInfo] if there isn't one already. This
* functions by finding the first known password node and taking the node directly above it.
*/
private fun findMissingUsernameNodeInfo(
allNodes: List<AccessibilityNodeInfo>,
passwordNodes: List<AccessibilityNodeInfo>,
): AccessibilityNodeInfo? =
passwordNodes
.firstOrNull()
?.let { allNodes.getOrNull(index = allNodes.indexOf(element = it) - 1) }
private fun log(message: String) {
if (!BuildConfig.DEBUG) return
Log.i("AccessibilityNodeInfoManager", message)
}
}

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.bitwarden.vault.CipherView
import kotlinx.coroutines.flow.Flow
/**
* A manager class used to handle the accessibility autofill selections.
*/
interface AccessibilitySelectionManager {
/**
* Emits a [CipherView] as a result of calls to [emitAccessibilitySelection].
*/
val accessibilitySelectionFlow: Flow<CipherView>
/**
* Triggers an emission via [accessibilitySelectionFlow].
*/
fun emitAccessibilitySelection(cipherView: CipherView)
}

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.bitwarden.vault.CipherView
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
/**
* The default implementation of the [AccessibilitySelectionManager].
*/
class AccessibilitySelectionManagerImpl : AccessibilitySelectionManager {
private val accessibilitySelectionChannel: Channel<CipherView> = Channel(
capacity = Int.MAX_VALUE,
)
override val accessibilitySelectionFlow: Flow<CipherView> =
accessibilitySelectionChannel.receiveAsFlow()
override fun emitAccessibilitySelection(cipherView: CipherView) {
accessibilitySelectionChannel.trySend(cipherView)
}
}

View File

@@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
/**
* A manager for getting the launcher packages from the operating system.
*/
interface LauncherPackageNameManager {
/**
* A list of launcher packages from the operating system.
*/
val launcherPackages: List<String>
}

View File

@@ -0,0 +1,41 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.content.Intent
import android.content.pm.PackageManager
import java.time.Clock
/**
* How frequently the cached launcher list should be refreshed.
*/
private const val REFRESH_CACHE_MS: Long = 1L * 60L * 60L * 1000L
/**
* The default implementation of the [LauncherPackageNameManager].
*/
class LauncherPackageNameManagerImpl(
private val clockProvider: () -> Clock,
private val packageManager: PackageManager,
) : LauncherPackageNameManager {
private var lastLauncherFetchMs: Long = 0L
private var cachedLauncherPackages: List<String>? = null
override val launcherPackages: List<String>
get() {
if (cachedLauncherPackages == null ||
clockProvider().millis() - lastLauncherFetchMs > REFRESH_CACHE_MS
) {
updateCachedLauncherPackages()
}
return cachedLauncherPackages.orEmpty()
}
private fun updateCachedLauncherPackages() {
cachedLauncherPackages = packageManager
.queryIntentActivities(
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
0,
)
.map { it.activityInfo.packageName }
lastLauncherFetchMs = clockProvider().millis()
}
}

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
import android.net.Uri
import com.bitwarden.vault.CipherView
/**
*Represents an action to be taken by the accessibility service.
*/
sealed class AccessibilityAction {
/**
* Indicates that the accessibility service should attempt to scan the currently foregrounded
* application for a [Uri].
*/
data object AttemptParseUri : AccessibilityAction()
/**
* Indicates that the accessibility service should attempt to scan the currently foregrounded
* application for a fields to fill.
*/
data class AttemptFill(
val cipherView: CipherView,
val uri: Uri,
) : AccessibilityAction()
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
/**
* A model representing a supported browser.
*/
data class Browser(
val packageName: String,
val possibleUrlFieldIds: List<String>,
val urlExtractor: (String) -> String? = { it },
) {
constructor(
packageName: String,
urlFieldId: String,
urlExtractor: (String) -> String? = { it },
) : this(
packageName = packageName,
possibleUrlFieldIds = listOf(urlFieldId),
urlExtractor = urlExtractor,
)
}

View File

@@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
import android.view.accessibility.AccessibilityNodeInfo
/**
* Represents the fillable fields for accessibility based autofill.
*/
data class FillableFields(
val usernameField: AccessibilityNodeInfo?,
val passwordFields: List<AccessibilityNodeInfo>,
)

View File

@@ -0,0 +1,71 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
/**
* Represents the known username fields for a given [uriAuthority].
*/
data class KnownUsernameField(
val uriAuthority: String,
val accessOptions: List<AccessOptions>,
) {
constructor(
uriAuthority: String,
accessOption: AccessOptions,
) : this(uriAuthority = uriAuthority, accessOptions = listOf(accessOption))
}
/**
* Represents the view IDs for a given uri path.
*/
data class AccessOptions(
val matchValue: String,
val matchingStrategy: MatchingStrategy = MatchingStrategy.ENDS_WITH_CASE_SENSITIVE,
val usernameViewIds: List<String>,
) {
constructor(
matchValue: String,
matchingStrategy: MatchingStrategy = MatchingStrategy.ENDS_WITH_CASE_SENSITIVE,
usernameViewId: String,
) : this(
matchValue = matchValue,
matchingStrategy = matchingStrategy,
usernameViewIds = listOf(usernameViewId),
)
/**
* Indicates the matching strategy needed for the particular [AccessOptions].
*/
enum class MatchingStrategy(
val matches: (uriPath: String, matchValue: String) -> Boolean,
) {
CONTAINS_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.contains(other = matchValue, ignoreCase = true)
},
),
CONTAINS_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.contains(other = matchValue, ignoreCase = false)
},
),
ENDS_WITH_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.endsWith(suffix = matchValue, ignoreCase = true)
},
),
ENDS_WITH_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.endsWith(suffix = matchValue, ignoreCase = false)
},
),
STARTS_WITH_CASE_INSENSITIVE(
matches = { uriPath, matchValue ->
uriPath.startsWith(prefix = matchValue, ignoreCase = true)
},
),
STARTS_WITH_CASE_SENSITIVE(
matches = { uriPath, matchValue ->
uriPath.startsWith(prefix = matchValue, ignoreCase = false)
},
),
}
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.autofill.accessibility.parser
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields
/**
* A tool for parsing accessibility data from the OS into domain models.
*/
interface AccessibilityParser {
/**
* Parses the fillable fields from [rootNode].
*/
fun parseForFillableFields(rootNode: AccessibilityNodeInfo, uri: Uri): FillableFields
/**
* Parses the [Uri] from [rootNode] and returns a url, package name.
*/
fun parseForUriOrPackageName(rootNode: AccessibilityNodeInfo): Uri?
}

View File

@@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.autofill.accessibility.parser
import android.net.Uri
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityNodeInfoManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields
import com.x8bit.bitwarden.data.autofill.accessibility.util.getSupportedBrowserOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.util.isEditText
import com.x8bit.bitwarden.data.autofill.accessibility.util.toUriOrNull
import com.x8bit.bitwarden.data.platform.util.hasHttpProtocol
/**
* The default implementation for the [AccessibilityParser].
*/
class AccessibilityParserImpl(
private val accessibilityNodeInfoManager: AccessibilityNodeInfoManager,
) : AccessibilityParser {
override fun parseForFillableFields(
rootNode: AccessibilityNodeInfo,
uri: Uri,
): FillableFields {
val nodes = accessibilityNodeInfoManager
.findAccessibilityNodeInfoList(rootNode = rootNode) {
it.isEditText || it.isPassword
}
val passwordNodes = nodes.filter { it.isPassword }
return FillableFields(
usernameField = accessibilityNodeInfoManager.findUsernameAccessibilityNodeInfo(
uri = uri,
allNodes = nodes,
passwordNodes = passwordNodes,
),
passwordFields = passwordNodes,
)
}
override fun parseForUriOrPackageName(rootNode: AccessibilityNodeInfo): Uri? {
val packageName = rootNode.packageName.toString()
val browser = packageName
.getSupportedBrowserOrNull()
?: return "androidapp://$packageName".toUri()
return browser
.possibleUrlFieldIds
.flatMap { viewId ->
rootNode
.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId")
.map { accessibilityNodeInfo ->
browser
.urlExtractor(accessibilityNodeInfo.text.toString())
?.trim()
?.let { rawUrl ->
if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) {
"https://$rawUrl"
} else {
rawUrl
}
}
}
}
.firstOrNull()
?.toUriOrNull()
}
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.autofill.accessibility.processor
import android.view.accessibility.AccessibilityNodeInfo
/**
* A class to handle accessibility event processing. This only includes fill requests.
*/
interface BitwardenAccessibilityProcessor {
/**
* Processes the [AccessibilityNodeInfo] for autofill options.
*/
fun processAccessibilityEvent(rootAccessibilityNodeInfo: AccessibilityNodeInfo?)
}

View File

@@ -0,0 +1,94 @@
package com.x8bit.bitwarden.data.autofill.accessibility.processor
import android.content.Context
import android.os.PowerManager
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Toast
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
import com.x8bit.bitwarden.data.autofill.accessibility.util.fillTextField
import com.x8bit.bitwarden.data.autofill.accessibility.util.isSystemPackage
import com.x8bit.bitwarden.data.autofill.accessibility.util.shouldSkipPackage
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionIntent
/**
* The default implementation of the [BitwardenAccessibilityProcessor].
*/
class BitwardenAccessibilityProcessorImpl(
private val context: Context,
private val accessibilityParser: AccessibilityParser,
private val accessibilityAutofillManager: AccessibilityAutofillManager,
private val launcherPackageNameManager: LauncherPackageNameManager,
private val powerManager: PowerManager,
) : BitwardenAccessibilityProcessor {
override fun processAccessibilityEvent(rootAccessibilityNodeInfo: AccessibilityNodeInfo?) {
val rootNode = rootAccessibilityNodeInfo ?: return
// Ignore the event when the phone is inactive
if (!powerManager.isInteractive) return
// We skip if the system package
if (rootNode.isSystemPackage) return
// We skip any package that is a launcher or unsupported
if (rootNode.shouldSkipPackage ||
launcherPackageNameManager.launcherPackages.any { it == rootNode.packageName }
) {
// Clear the action since this event needs to be ignored completely
accessibilityAutofillManager.accessibilityAction = null
return
}
// Only process the event if the tile was clicked
val accessibilityAction = accessibilityAutofillManager.accessibilityAction ?: return
accessibilityAutofillManager.accessibilityAction = null
when (accessibilityAction) {
is AccessibilityAction.AttemptFill -> {
handleAttemptFill(rootNode = rootNode, attemptFill = accessibilityAction)
}
AccessibilityAction.AttemptParseUri -> handleAttemptParseUri(rootNode = rootNode)
}
}
private fun handleAttemptParseUri(rootNode: AccessibilityNodeInfo) {
accessibilityParser
.parseForUriOrPackageName(rootNode = rootNode)
?.let { uri ->
context.startActivity(
createAutofillSelectionIntent(
context = context,
framework = AutofillSelectionData.Framework.ACCESSIBILITY,
type = AutofillSelectionData.Type.LOGIN,
uri = uri.toString(),
),
)
}
?: run {
Toast
.makeText(
context,
R.string.autofill_tile_uri_not_found,
Toast.LENGTH_LONG,
)
.show()
}
}
private fun handleAttemptFill(
rootNode: AccessibilityNodeInfo,
attemptFill: AccessibilityAction.AttemptFill,
) {
val loginView = attemptFill.cipherView.login ?: return
val fields = accessibilityParser.parseForFillableFields(
rootNode = rootNode,
uri = attemptFill.uri,
)
fields.usernameField?.fillTextField(value = loginView.username)
fields.passwordFields.forEach { passwordField ->
passwordField.fillTextField(value = loginView.password)
}
}
}

View File

@@ -0,0 +1,91 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.EditText
import androidx.core.os.bundleOf
import com.x8bit.bitwarden.data.autofill.accessibility.model.KnownUsernameField
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
private const val PACKAGE_NAME_BITWARDEN_PREFIX: String = "com.x8bit.bitwarden"
private const val PACKAGE_NAME_SYSTEM_UI: String = "com.android.systemui"
private const val PACKAGE_NAME_LAUNCHER_PARTIAL: String = "launcher"
private val PACKAGE_NAME_BLOCK_LIST: List<String> = listOf(
"com.google.android.googlequicksearchbox",
"com.google.android.apps.nexuslauncher",
"com.google.android.launcher",
"com.computer.desktop.ui.launcher",
"com.launcher.notelauncher",
"com.anddoes.launcher",
"com.actionlauncher.playstore",
"ch.deletescape.lawnchair.plah",
"com.microsoft.launcher",
"com.teslacoilsw.launcher",
"com.teslacoilsw.launcher.prime",
"is.shortcut",
"me.craftsapp.nlauncher",
"com.ss.squarehome2",
"com.treydev.pns",
)
/**
* Returns true if the event is for an unsupported package.
*/
val AccessibilityNodeInfo.shouldSkipPackage: Boolean
get() {
val packageName = this.packageName.takeUnless { it.isNullOrBlank() } ?: return true
if (packageName.startsWith(prefix = PACKAGE_NAME_BITWARDEN_PREFIX)) return true
if (packageName.contains(other = PACKAGE_NAME_LAUNCHER_PARTIAL, ignoreCase = true)) {
return true
}
return PACKAGE_NAME_BLOCK_LIST.contains(packageName)
}
/**
* Returns true if the event is from the system UI package.
*/
val AccessibilityNodeInfo.isSystemPackage: Boolean
get() = this.packageName == PACKAGE_NAME_SYSTEM_UI
/**
* Fills the [AccessibilityNodeInfo] text field with the [value] provided.
*/
@OmitFromCoverage
fun AccessibilityNodeInfo.fillTextField(value: String?) {
performAction(
AccessibilityNodeInfo.ACTION_SET_TEXT,
bundleOf(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE to value),
)
}
/**
* Determines if the [AccessibilityNodeInfo] is an instance of an EditText.
*/
val AccessibilityNodeInfo.isEditText: Boolean
get() = className
?.let {
try {
Class.forName(it.toString())
} catch (e: ClassNotFoundException) {
null
}
}
?.let { EditText::class.java.isAssignableFrom(it) }
?: (className?.contains(other = "EditText") == true)
/**
* Determines if the [AccessibilityNodeInfo] is a username field.
*/
fun AccessibilityNodeInfo.isUsername(
knownUsernameField: KnownUsernameField,
uriPath: String,
): Boolean {
knownUsernameField.accessOptions.map { options ->
if (options.matchingStrategy.matches(uriPath, options.matchValue)) {
options
.usernameViewIds
.firstOrNull { viewId -> viewId == this@isUsername.viewIdResourceName }
?.let { return true }
}
}
return false
}

View File

@@ -0,0 +1,219 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser
/**
* Determines if the [String] receiver is a package name for a supported browser and returns that
* [Browser] if it is a match.
*/
fun String.getSupportedBrowserOrNull(): Browser? =
ACCESSIBILITY_SUPPORTED_BROWSERS.find { it.packageName == this@getSupportedBrowserOrNull }
/**
* A list of supported browsers and the field ID used to find the url bar.
*
* This list should be kept in order and match the list of compatibility browsers in the
* autofill_service_configuration.xml.
*/
private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(packageName = "alook.browser", urlFieldId = "search_fragment_input_view"),
Browser(packageName = "alook.browser.google", urlFieldId = "search_fragment_input_view"),
Browser(packageName = "app.vanadium.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.amazon.cloud9", urlFieldId = "url"),
Browser(packageName = "com.android.browser", urlFieldId = "url"),
Browser(packageName = "com.android.chrome", urlFieldId = "url_bar"),
// "com.android.htmlviewer": Doesn't have a URL bar
Browser(packageName = "com.avast.android.secure.browser", urlFieldId = "editor"),
Browser(packageName = "com.avg.android.secure.browser", urlFieldId = "editor"),
Browser(packageName = "com.brave.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.brave.browser_beta", urlFieldId = "url_bar"),
Browser(packageName = "com.brave.browser_default", urlFieldId = "url_bar"),
Browser(packageName = "com.brave.browser_dev", urlFieldId = "url_bar"),
Browser(packageName = "com.brave.browser_nightly", urlFieldId = "url_bar"),
Browser(packageName = "com.chrome.beta", urlFieldId = "url_bar"),
Browser(packageName = "com.chrome.canary", urlFieldId = "url_bar"),
Browser(packageName = "com.chrome.dev", urlFieldId = "url_bar"),
Browser(packageName = "com.cookiegames.smartcookie", urlFieldId = "search"),
Browser(
packageName = "com.cookiejarapps.android.smartcookieweb",
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "com.duckduckgo.mobile.android", urlFieldId = "omnibarTextInput"),
Browser(packageName = "com.ecosia.android", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome_dev", urlFieldId = "url_bar"),
// "com.google.android.captiveportallogin": URL displayed in ActionBar subtitle without viewId
Browser(packageName = "com.iode.firefox", urlFieldId = "mozac_browser_toolbar_url_view"),
Browser(packageName = "com.jamal2367.styx", urlFieldId = "search"),
Browser(packageName = "com.kiwibrowser.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.kiwibrowser.browser.dev", urlFieldId = "url_bar"),
Browser(packageName = "com.microsoft.emmx", urlFieldId = "url_bar"),
Browser(packageName = "com.microsoft.emmx.beta", urlFieldId = "url_bar"),
Browser(packageName = "com.microsoft.emmx.canary", urlFieldId = "url_bar"),
Browser(packageName = "com.microsoft.emmx.dev", urlFieldId = "url_bar"),
Browser(packageName = "com.mmbox.browser", urlFieldId = "search_box"),
Browser(packageName = "com.mmbox.xbrowser", urlFieldId = "search_box"),
Browser(packageName = "com.mycompany.app.soulbrowser", urlFieldId = "edit_text"),
Browser(packageName = "com.naver.whale", urlFieldId = "url_bar"),
Browser(packageName = "com.neeva.app", urlFieldId = "full_url_text_view"),
Browser(packageName = "com.opera.browser", urlFieldId = "url_field"),
Browser(packageName = "com.opera.browser.beta", urlFieldId = "url_field"),
Browser(packageName = "com.opera.gx", urlFieldId = "addressbarEdit"),
Browser(packageName = "com.opera.mini.native", urlFieldId = "url_field"),
Browser(packageName = "com.opera.mini.native.beta", urlFieldId = "url_field"),
Browser(packageName = "com.opera.touch", urlFieldId = "addressbarEdit"),
Browser(packageName = "com.qflair.browserq", urlFieldId = "url"),
Browser(
packageName = "com.qwant.liberty",
// 2nd = Legacy (before v4)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "com.rainsee.create", urlFieldId = "search_box"),
Browser(packageName = "com.sec.android.app.sbrowser", urlFieldId = "location_bar_edit_text"),
Browser(
packageName = "com.sec.android.app.sbrowser.beta",
urlFieldId = "location_bar_edit_text",
),
Browser(packageName = "com.stoutner.privacybrowser.free", urlFieldId = "url_edittext"),
Browser(packageName = "com.stoutner.privacybrowser.standard", urlFieldId = "url_edittext"),
Browser(packageName = "com.vivaldi.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.vivaldi.browser.snapshot", urlFieldId = "url_bar"),
Browser(packageName = "com.vivaldi.browser.sopranos", urlFieldId = "url_bar"),
Browser(
packageName = "com.yandex.browser",
possibleUrlFieldIds = listOf(
"bro_omnibar_address_title_text",
"bro_omnibox_collapsed_title",
),
urlExtractor = {
// 0 = Regular Space, 1 = No-break space (00A0)
it.split(' ', ' ').firstOrNull()
},
),
Browser(packageName = "com.yjllq.internet", urlFieldId = "search_box"),
Browser(packageName = "com.yjllq.kito", urlFieldId = "search_box"),
Browser(packageName = "com.yujian.ResideMenuDemo", urlFieldId = "search_box"),
Browser(packageName = "com.z28j.feel", urlFieldId = "g2"),
Browser(packageName = "idm.internet.download.manager", urlFieldId = "search"),
Browser(packageName = "idm.internet.download.manager.adm.lite", urlFieldId = "search"),
Browser(packageName = "idm.internet.download.manager.plus", urlFieldId = "search"),
Browser(
packageName = "io.github.forkmaintainers.iceraven",
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "mark.via", urlFieldId = "am,an"),
Browser(packageName = "mark.via.gp", urlFieldId = "as"),
Browser(packageName = "net.dezor.browser", urlFieldId = "url_bar"),
Browser(packageName = "net.slions.fulguris.full.download", urlFieldId = "search"),
Browser(packageName = "net.slions.fulguris.full.download.debug", urlFieldId = "search"),
Browser(packageName = "net.slions.fulguris.full.playstore", urlFieldId = "search"),
Browser(packageName = "net.slions.fulguris.full.playstore.debug", urlFieldId = "search"),
Browser(
packageName = "org.adblockplus.browser",
// 2nd = Legacy (before v2)
possibleUrlFieldIds = listOf("url_bar", "url_bar_title"),
),
Browser(
packageName = "org.adblockplus.browser.beta",
// 2nd = Legacy (before v2)
possibleUrlFieldIds = listOf("url_bar", "url_bar_title"),
),
Browser(packageName = "org.bromite.bromite", urlFieldId = "url_bar"),
Browser(packageName = "org.bromite.chromium", urlFieldId = "url_bar"),
Browser(packageName = "org.chromium.chrome", urlFieldId = "url_bar"),
Browser(packageName = "org.codeaurora.swe.browser", urlFieldId = "url_bar"),
Browser(packageName = "org.cromite.cromite", urlFieldId = "url_bar"),
Browser(
packageName = "org.gnu.icecat",
// 2nd = Anticipation
possibleUrlFieldIds = listOf("url_bar_title", "mozac_browser_toolbar_url_view"),
),
Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fenix.nightly",
urlFieldId = "mozac_browser_toolbar_url_view",
),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fennec_aurora",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.fennec_fdroid",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.firefox",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.firefox_beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.focus",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.focus.beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.focus.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.klar",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.reference.browser",
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "org.mozilla.rocket", urlFieldId = "display_url"),
Browser(
packageName = "org.torproject.torbrowser",
// 2nd = Legacy (before v10.0.3)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.torproject.torbrowser_alpha",
// 2nd = Legacy (before v10.0a8)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "org.ungoogled.chromium.extensions.stable", urlFieldId = "url_bar"),
Browser(packageName = "org.ungoogled.chromium.stable", urlFieldId = "url_bar"),
Browser(
packageName = "us.spotco.fennec_dos",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
// [Section B] Entries only present here
// TODO: Test the compatibility of these with Autofill Framework
Browser(packageName = "acr.browser.barebones", urlFieldId = "search"),
Browser(packageName = "acr.browser.lightning", urlFieldId = "search"),
Browser(packageName = "com.feedback.browser.wjbrowser", urlFieldId = "addressbar_url"),
Browser(packageName = "com.ghostery.android.ghostery", urlFieldId = "search_field"),
Browser(packageName = "com.htc.sense.browser", urlFieldId = "title"),
Browser(packageName = "com.jerky.browser2", urlFieldId = "enterUrl"),
Browser(packageName = "com.ksmobile.cb", urlFieldId = "address_bar_edit_text"),
Browser(packageName = "com.lemurbrowser.exts", urlFieldId = "url_bar"),
Browser(packageName = "com.linkbubble.playstore", urlFieldId = "url_text"),
Browser(packageName = "com.mx.browser", urlFieldId = "address_editor_with_progress"),
Browser(packageName = "com.mx.browser.tablet", urlFieldId = "address_editor_with_progress"),
Browser(packageName = "com.nubelacorp.javelin", urlFieldId = "enterUrl"),
Browser(packageName = "jp.co.fenrir.android.sleipnir", urlFieldId = "url_text"),
Browser(packageName = "jp.co.fenrir.android.sleipnir_black", urlFieldId = "url_text"),
Browser(packageName = "jp.co.fenrir.android.sleipnir_test", urlFieldId = "url_text"),
Browser(packageName = "mobi.mgeek.TunnyBrowser", urlFieldId = "title"),
Browser(packageName = "org.iron.srware", urlFieldId = "url_bar"),
)

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.content.Context
import android.provider.Settings
import com.x8bit.bitwarden.LEGACY_ACCESSIBILITY_SERVICE_NAME
import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService
/**
* Helper method to determine if the [BitwardenAccessibilityService] is enabled.
*/
val Context.isAccessibilityServiceEnabled: Boolean
get() {
val appContext = this.applicationContext
val accessibilityServiceName = appContext
.packageName
?.let { "$it/$LEGACY_ACCESSIBILITY_SERVICE_NAME" }
?: return false
return Settings
.Secure
.getString(
appContext.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
)
?.contains(accessibilityServiceName)
?: false
}

View File

@@ -0,0 +1,672 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessOptions
import com.x8bit.bitwarden.data.autofill.accessibility.model.KnownUsernameField
/**
* Determines if the [String] receiver is a uri authority for a known username field and returns
* that [KnownUsernameField] if it is a match.
*/
fun String.getKnownUsernameFieldNull(): KnownUsernameField? =
LEGACY_KNOWN_USERNAME_FIELDS.find { it.uriAuthority == this@getKnownUsernameFieldNull }
/**
* A list of known username fields and their IDs.
*/
private val LEGACY_KNOWN_USERNAME_FIELDS: List<KnownUsernameField> = listOf(
// SECTION A ——— World-renowned web sites/applications
KnownUsernameField(
uriAuthority = "amazon.ae",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.ca",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.cn",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.co.jp",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.co.uk",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.au",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.br",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.mx",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.com.tr",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.de",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.es",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.fr",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.in",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.it",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.nl",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.pl",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.sa",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.se",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "amazon.sg",
accessOption = AccessOptions(
matchValue = "/ap/signin",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewIds = listOf("ap_email_login", "ap_email"),
),
),
KnownUsernameField(
uriAuthority = "signin.aws.amazon.com",
accessOption = AccessOptions(matchValue = "signin", usernameViewId = "resolving_input"),
),
KnownUsernameField(
uriAuthority = "id.atlassian.com",
accessOption = AccessOptions(matchValue = "login", usernameViewId = "username"),
),
KnownUsernameField(
uriAuthority = "bitly.com",
accessOption = AccessOptions(matchValue = "/sso/url_slug", usernameViewId = "url_slug"),
),
KnownUsernameField(
uriAuthority = "signin.befr.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.benl.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.cafr.ebay.ca",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.at",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.be",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ca",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ch",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.co.uk",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.au",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.hk",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.my",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.com.sg",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.de",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.es",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.fr",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ie",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.in",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.it",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.nl",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.ph",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "signin.ebay.pl",
accessOptions = listOf(
AccessOptions(
matchValue = "eBayISAPI.dll",
matchingStrategy = AccessOptions.MatchingStrategy.ENDS_WITH_CASE_INSENSITIVE,
usernameViewId = "userid",
),
AccessOptions(
matchValue = "/signin/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_INSENSITIVE,
usernameViewId = "userid",
),
),
),
KnownUsernameField(
uriAuthority = "accounts.google.com",
accessOptions = listOf(
AccessOptions(matchValue = "identifier", usernameViewId = "identifierId"),
AccessOptions(matchValue = "ServiceLogin", usernameViewId = "Email"),
),
),
KnownUsernameField(
uriAuthority = "paypal.com",
accessOptions = listOf(
AccessOptions(matchValue = "signin", usernameViewId = "email"),
AccessOptions(
matchValue = "/connect/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "email",
),
),
),
KnownUsernameField(
uriAuthority = "tumblr.com",
accessOption = AccessOptions(
matchValue = "login",
usernameViewId = "signup_determine_email",
),
),
KnownUsernameField(
uriAuthority = "passport.yandex.az",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.by",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.co.il",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.am",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.ge",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.com.tr",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ee",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.fi",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.fr",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.kg",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.kz",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.lt",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.lv",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.md",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.pl",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ru",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.tj",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.tm",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.ua",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
KnownUsernameField(
uriAuthority = "passport.yandex.uz",
accessOption = AccessOptions(matchValue = "auth", usernameViewId = "passp-field-login"),
),
// SECTION B ——— Top 100 worldwide
// As of July 2020, all entries that needed to be added from
// Top 100 (SimilarWeb, 2019) and Top 50 (Alexa Internet, 2020)
// matched section A.
// Therefore, no entry currently.
// SECTION C ——— Top 20 for selected countries
// For these selected countries, the Top 20 (SimilarWeb, 2020)
// and the Top 20 (Alexa Internet, 2020) are covered.
// Mobile and desktop versions supported.
// Could not be added, however:
// web sites/applications that don't use an "id" attribute for their login field.
KnownUsernameField(
uriAuthority = "cfg.smt.docomo.ne.jp",
accessOption = AccessOptions(
matchValue = "/auth/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "Di_Uid",
),
),
KnownUsernameField(
uriAuthority = "id.smt.docomo.ne.jp",
accessOption = AccessOptions(
matchValue = "/cgi7/",
matchingStrategy = AccessOptions.MatchingStrategy.CONTAINS_CASE_SENSITIVE,
usernameViewId = "Di_Uid",
),
),
// SECTION D ——— Miscellaneous
// No entry, currently.
// SECTION Z ——— Special forms
// Despite "user ID + password" fields both visible, detection rules required.
// No entry, currently.
// Test/example purposes only
// GitHub is a VERY special case (signup form, just to test the proper functioning
// of special forms).
KnownUsernameField(
uriAuthority = "github.com",
accessOption = AccessOptions(matchValue = "", usernameViewId = "user[login]-footer"),
),
)

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.net.URISyntaxException
/**
* Attempts to parse a [Uri] from a string and returns null if an error occurs.
*/
@OmitFromCoverage
fun String.toUriOrNull(): Uri? =
try {
Uri.parse(this)
} catch (e: URISyntaxException) {
null
}

View File

@@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManagerImpl
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.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
@@ -32,6 +34,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@@ -56,21 +59,15 @@ object AutofillModule {
@Provides
fun provideAutofillCompletionManager(
autofillParser: AutofillParser,
authRepository: AuthRepository,
clipboardManager: BitwardenClipboardManager,
dispatcherManager: DispatcherManager,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
organizationEventManager: OrganizationEventManager,
totpManager: AutofillTotpManager,
): AutofillCompletionManager =
AutofillCompletionManagerImpl(
authRepository = authRepository,
autofillParser = autofillParser,
clipboardManager = clipboardManager,
dispatcherManager = dispatcherManager,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
organizationEventManager = organizationEventManager,
totpManager = totpManager,
)
@Singleton
@@ -82,6 +79,25 @@ object AutofillModule {
settingsRepository = settingsRepository,
)
@Singleton
@Provides
fun providesAutofillTotpManager(
@ApplicationContext context: Context,
clock: Clock,
clipboardManager: BitwardenClipboardManager,
authRepository: AuthRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
): AutofillTotpManager =
AutofillTotpManagerImpl(
context = context,
clock = clock,
clipboardManager = clipboardManager,
authRepository = authRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)
@Singleton
@Provides
fun providesAutofillCipherProvider(

View File

@@ -21,6 +21,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
/**
@@ -41,6 +42,7 @@ object Fido2ProviderModule {
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
clock: Clock,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
@@ -49,6 +51,7 @@ object Fido2ProviderModule {
fido2CredentialStore,
fido2CredentialManager,
intentManager,
clock,
dispatcherManager,
)

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
@@ -26,10 +27,11 @@ interface Fido2CredentialManager {
var authenticationAttempts: Int
/**
* Attempt to validate the RP and origin of the provided [fido2CredentialRequest].
* Attempt to validate the RP and origin of the provided [callingAppInfo] and [relyingPartyId].
*/
suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult
/**

View File

@@ -14,9 +14,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.data.platform.util.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
@@ -55,18 +53,18 @@ class Fido2CredentialManagerImpl(
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult {
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest
fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.packageName,
)
}
.packageName,
)
}
val origin = fido2CredentialRequest
.origin
?: getOriginUrlFromAttestationOptionsOrNull(fido2CredentialRequest.requestJson)
@@ -95,13 +93,13 @@ class Fido2CredentialManagerImpl(
}
override suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
val callingAppInfo = fido2CredentialRequest.callingAppInfo
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(fido2CredentialRequest)
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
}
}
@@ -136,40 +134,52 @@ class Fido2CredentialManagerImpl(
val clientData = request.clientDataHash
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.getAppOrigin())
val origin = request.origin
val origin = callingAppInfo.origin
?: getOriginUrlFromAssertionOptionsOrNull(request.requestJson)
?: return Fido2CredentialAssertionResult.Error
val relyingPartyId = json
.decodeFromStringOrNull<PasskeyAssertionOptions>(request.requestJson)
?.relyingPartyId
?: return Fido2CredentialAssertionResult.Error
return vaultSdkSource
.authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${request.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
isUserVerificationSupported = true,
),
fido2CredentialStore = this,
)
.map { it.toAndroidFido2PublicKeyCredential() }
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2CredentialAssertionResult.Success(it) },
onFailure = { Fido2CredentialAssertionResult.Error },
)
val validateOriginResult = validateOrigin(
callingAppInfo = callingAppInfo,
relyingPartyId = relyingPartyId,
)
return when (validateOriginResult) {
is Fido2ValidateOriginResult.Error -> {
Fido2CredentialAssertionResult.Error
}
Fido2ValidateOriginResult.Success -> {
vaultSdkSource
.authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${request.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
isUserVerificationSupported = true,
),
fido2CredentialStore = this,
)
.map { it.toAndroidFido2PublicKeyCredential() }
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2CredentialAssertionResult.Success(it) },
onFailure = { Fido2CredentialAssertionResult.Error },
)
}
}
}
private suspend fun validateCallingApplicationAssetLinks(
fido2CredentialRequest: Fido2CredentialRequest,
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
val callingAppInfo = fido2CredentialRequest.callingAppInfo
return fido2CredentialRequest
.requestJson
.getRpId(json)
.flatMap { rpId ->
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = rpId)
}
return digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = relyingPartyId)
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
@@ -247,20 +257,6 @@ class Fido2CredentialManagerImpl(
}
.takeUnless { it.isEmpty() }
private fun String.getRpId(json: Json): Result<String> {
return try {
json
.decodeFromString<PasskeyAttestationOptions>(this)
.relyingParty
.id
.asSuccess()
} catch (e: SerializationException) {
e.asFailure()
} catch (e: IllegalArgumentException) {
e.asFailure()
}
}
override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS

View File

@@ -38,6 +38,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAut
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
@@ -57,6 +58,7 @@ class Fido2ProviderProcessorImpl(
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
@@ -111,13 +113,14 @@ class Fido2ProviderProcessorImpl(
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(userState.accounts.toCreateEntries())
.setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId))
.build()
}
private fun List<UserState.Account>.toCreateEntries() = map { it.toCreateEntry() }
private fun List<UserState.Account>.toCreateEntries(activeUserId: String) =
map { it.toCreateEntry(isActive = activeUserId == it.userId) }
private fun UserState.Account.toCreateEntry(): CreateEntry {
private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry {
val accountName = name ?: email
return CreateEntry
.Builder(
@@ -134,6 +137,9 @@ class Fido2ProviderProcessorImpl(
accountName,
),
)
// Set the last used time to "now" so the active account is the default option in the
// system prompt.
.setLastUsedTime(if (isActive) clock.instant() else null)
.build()
}

View File

@@ -2,11 +2,7 @@ package com.x8bit.bitwarden.data.autofill.manager
import android.app.Activity
import android.content.Intent
import android.widget.Toast
import com.bitwarden.core.DateTime
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilderImpl
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
@@ -16,13 +12,9 @@ import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionResultInten
import com.x8bit.bitwarden.data.autofill.util.getAutofillAssistStructureOrNull
import com.x8bit.bitwarden.data.autofill.util.toAutofillAppInfo
import com.x8bit.bitwarden.data.autofill.util.toAutofillCipherProvider
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -31,15 +23,12 @@ import kotlinx.coroutines.launch
*/
@Suppress("LongParameterList")
class AutofillCompletionManagerImpl(
private val authRepository: AuthRepository,
private val autofillParser: AutofillParser,
private val clipboardManager: BitwardenClipboardManager,
dispatcherManager: DispatcherManager,
private val filledDataBuilderProvider: (CipherView) -> FilledDataBuilder =
{ createSingleItemFilledDataBuilder(cipherView = it) },
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val organizationEventManager: OrganizationEventManager,
private val totpManager: AutofillTotpManager,
) : AutofillCompletionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
@@ -82,10 +71,7 @@ class AutofillCompletionManagerImpl(
activity.cancelAndFinish()
return@launch
}
tryCopyTotpToClipboard(
activity = activity,
cipherView = cipherView,
)
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
val resultIntent = createAutofillSelectionResultIntent(dataset)
activity.setResultAndFinish(resultIntent = resultIntent)
cipherView.id?.let {
@@ -95,40 +81,6 @@ class AutofillCompletionManagerImpl(
}
}
}
/**
* Attempt to copy the totp code to clipboard. If it succeeds show a toast.
*
* @param activity An activity for launching a toast.
* @param cipherView The [CipherView] for which to generate a TOTP code.
*/
private suspend fun tryCopyTotpToClipboard(
activity: Activity,
cipherView: CipherView,
) {
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
val totpAvailableViaPremiumOrOrganization = isPremium || cipherView.organizationUseTotp
val totpCode = cipherView.login?.totp
val isTotpDisabled = settingsRepository.isAutoCopyTotpDisabled
if (!isTotpDisabled && totpAvailableViaPremiumOrOrganization && totpCode != null) {
val totpResult = vaultRepository.generateTotp(
time = DateTime.now(),
totpCode = totpCode,
)
if (totpResult is GenerateTotpResult.Success) {
clipboardManager.setText(totpResult.code)
Toast
.makeText(
activity.applicationContext,
R.string.verification_code_totp,
Toast.LENGTH_LONG,
)
.show()
}
}
}
}
private fun createSingleItemFilledDataBuilder(

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.vault.CipherView
/**
* Manages copying the totp code to the clipboard for autofill.
*/
interface AutofillTotpManager {
/**
* Attempt to copy the totp code to clipboard. If it succeeds show a toast.
*/
suspend fun tryCopyTotpToClipboard(cipherView: CipherView)
}

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