Compare commits

..

128 Commits

Author SHA1 Message Date
Amy Galles
14e0a1ea6e fix final step to run on dry run 2025-10-09 15:48:18 -07:00
Amy Galles
30cd148d5f point to existing workflow 2025-10-09 15:38:32 -07:00
Amy Galles
878ef0d74a add test options 2025-10-09 15:34:12 -07:00
Amy Galles
8387aed394 add test options 2025-10-09 15:31:21 -07:00
Amy Galles
3f875d83ff reverse recent name change 2025-10-09 15:24:17 -07:00
Amy Galles
85951a502f Merge remote-tracking branch 'refs/remotes/origin/agalles/BRE-1118' into agalles/BRE-1118 2025-10-09 15:19:36 -07:00
Amy Galles
04c38101b2 change back to pm from bwpm for simplicity 2025-10-09 15:19:10 -07:00
Amy Galles
7e080775f6 fixing if then logic 2025-10-08 11:41:01 -07:00
Amy Galles
a90a11d5b1 Apply suggestions from code review
Co-authored-by: Andy Pixley <3723676+pixman20@users.noreply.github.com>
2025-10-07 12:21:22 -07:00
Amy Galles
eea675f3b0 changing pwm to bwpm 2025-10-03 14:54:17 -07:00
Amy Galles
55c150a595 fixing logic for new filenames 2025-10-03 14:22:48 -07:00
Amy Galles
274e8293a3 Apply suggestions from code review
Co-authored-by: Andy Pixley <3723676+pixman20@users.noreply.github.com>
2025-10-03 14:18:35 -07:00
Amy Galles
c8710588b9 remove unnecessary permissions 2025-10-03 11:12:58 -07:00
Amy Galles
251d3001c2 Merge branch 'main' into agalles/BRE-1118 2025-10-03 07:10:56 -07:00
Patrick Honkonen
acc9113f9a [PM-26355] Improve SelectAccountScreen state handling (#5965) 2025-10-02 21:05:08 +00:00
David Perez
2eb829a25b [deps]: Update org.sonarqube to v6.3.1.5724 (#5973) 2025-10-02 20:51:02 +00:00
Álison Fernandes
04a1d4118f Update renovate.json to exclude com.github.bumptech.glide from gradle-minor group (#5974) 2025-10-02 20:39:47 +00:00
Amy Galles
7e20acd6a2 enable publish workflow only if success 2025-10-02 13:10:00 -07:00
Amy Galles
dd2cf0fe0a splitting password manager and authenticator workflows 2025-10-02 11:51:31 -07:00
David Perez
9f63cede11 Update UI elements for common use in Authenticator (#5971) 2025-10-02 18:37:17 +00:00
David Perez
a93037d63e PM-26445: Common Debug menu components (#5970) 2025-10-02 17:32:22 +00:00
Patrick Honkonen
4e57f306d3 [PM-26330] Correct owner data when individual vault is disabled (#5968) 2025-10-02 15:56:50 +00:00
André Bispo
1638a20bf0 [PM-23280] Save MasterPasswordUnlockData to local state (#5944) 2025-10-02 14:48:28 +00:00
bw-ghapp[bot]
874edfad69 Update SDK to 1.0.0-3194-9947387b (#5938)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Hinton <hinton@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-10-01 17:15:23 +00:00
David Perez
0469731fba Update Kover to v0.9.2 (#5966) 2025-10-01 17:08:54 +00:00
David Perez
0abfa5bb97 Update Androidx Camera to v1.5.0 (#5896) 2025-10-01 17:08:10 +00:00
aj-rosado
13e6728d46 [PM-17870] Always include clientExtensionResults in Fido2AttestationResponse (#5964) 2025-10-01 13:58:57 +00:00
Amy Galles
4749080fa5 restore setting to run weekdays 2025-09-30 16:21:15 -07:00
Amy Galles
836b3ccf1f remove test settings 2025-09-30 16:19:04 -07:00
David Perez
116bfd6351 PM-26312: Add browser integration help link (#5963) 2025-09-30 17:47:43 +00:00
David Perez
6ca8a39355 Update Guava to v33.5.0 (#5962) 2025-09-30 17:20:31 +00:00
David Perez
24a54ce214 Update hilt to v2.57.2 (#5961) 2025-09-30 17:20:15 +00:00
David Perez
8d76ef50d3 Firebase BOM update (#5960) 2025-09-30 17:19:59 +00:00
David Perez
22114d588a Update AndroidX libraries (#5959) 2025-09-29 21:39:52 +00:00
Patrick Honkonen
81245cf3e5 [PM-26111] Implement Review Export Screen and Navigation (#5946) 2025-09-29 21:12:09 +00:00
aj-rosado
fec6479f6a [PM-25452] Dont show move to organization when user has no orgs (#5862) 2025-09-29 20:01:32 +00:00
David Perez
a02a84ee08 PM-25642: Force sync or clear last sync time on sync notification (#5958) 2025-09-29 19:45:56 +00:00
Tyler
df63bb4b6c BRE-1158 Dockerfiles shared ownership (#5902) 2025-09-29 19:23:11 +00:00
David Perez
2a134c619d Update the Compose BOM (#5957) 2025-09-29 19:21:36 +00:00
Patrick Honkonen
5c5bd25d16 [PM-26094] Update Credential Manager library and remove stubs (#5947) 2025-09-29 18:41:35 +00:00
David Perez
2363b0d619 PM-26303: Remoe the 'Exit' button from the VaultScreen overflow menu (#5956) 2025-09-29 16:35:25 +00:00
David Perez
f0946e05d5 Fully extract more sync logic into the VaultSyncManager (#5912) 2025-09-29 16:35:00 +00:00
renovate[bot]
24ccebd822 [deps]: Lock file maintenance (#5954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 13:16:29 +00:00
David Perez
fd555e92d3 Commonize minor UI utility functions (#5945) 2025-09-26 20:34:25 +00:00
David Perez
eab2c17614 PM-26187: Add autofill help call-to-action (#5942) 2025-09-26 19:42:51 +00:00
David Perez
617be1fd95 PM-26181: Minor clean up and adjustments for browser autofill integration (#5941) 2025-09-26 15:10:31 +00:00
David Perez
d5d4caea62 PM-23292: Migrate toasts to snackbars (#5940) 2025-09-26 15:09:35 +00:00
Patrick Honkonen
7bf4acbb28 [PM-26110] Add verify password screen for item export (#5935) 2025-09-26 14:57:59 +00:00
André Bispo
2694138aa1 [PM-20977] Handle new sdk exception type. (#5937) 2025-09-26 14:47:21 +00:00
David Perez
d2645863ea PM-26161: Add badging for browser autofill (#5939) 2025-09-25 18:01:14 +00:00
Patrick Honkonen
3edd5bd852 [PM-26095] Add account selection screen for Credential Exchange (#5932) 2025-09-24 19:53:40 +00:00
David Perez
4cd5a1ed56 PM-26025: Add browser autofill screen for onboarding flow (#5931) 2025-09-24 19:50:13 +00:00
David Perez
c122f83fa6 Update onboarding secondary buttons to match designs (#5936) 2025-09-24 19:10:07 +00:00
bw-ghapp[bot]
b558d70703 Update SDK to 1.0.0-3175-c9758478 (#5922)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-24 17:52:04 +00:00
David Perez
89ad7818f9 Minor design tweaks for action cards (#5934) 2025-09-24 17:17:46 +00:00
David Perez
e91ba77105 PM-26151: Disable continue button for Autofill onboarding flow when autofill is disabled (#5933) 2025-09-24 16:55:50 +00:00
Patrick Honkonen
cc685b2307 [PM-26112] Handle Credential Exchange export requests (#5928) 2025-09-23 21:41:58 +00:00
David Perez
d14fba0c01 Remove unnecessary quotes (#5929) 2025-09-23 20:27:38 +00:00
Patrick Honkonen
e965134697 Update Credential Provider Events APIs (#5926) 2025-09-23 18:58:28 +00:00
David Perez
df34db52e4 PM-26106: Update quotes accross all strings (#5924) 2025-09-23 18:20:57 +00:00
David Perez
cf5d208516 Display the CipherKeyEncryption flag in debug menu (#5923) 2025-09-23 16:04:36 +00:00
André Bispo
d74040e7b9 [PM-25933] Replace SDK call updatePassword (#5916) 2025-09-23 15:11:07 +00:00
Patrick Honkonen
8a2bcfade8 [PM-25825] Add ImportItems navigation (#5915)
Co-authored-by: David Perez <david@livefront.com>
2025-09-22 21:33:08 +00:00
bw-ghapp[bot]
bc1dd730ec Update SDK to 1.0.0-3165-92bb5c30 (#5920)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-09-22 20:32:27 +00:00
David Perez
fa5053b5cc Add empty state for debug menu without feature flags (#5918) 2025-09-22 20:30:25 +00:00
bw-ghapp[bot]
ad46d8d7c0 Update SDK to 1.0.0-3157-1ca5a589 (#5917)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-09-22 15:43:16 +00:00
David Perez
98530ed33d PM-26027: Remove the UserManagedPrivilegedApps feature flag (#5914) 2025-09-19 20:09:54 +00:00
David Perez
e57af949fc PM-26026: save layout state through config change (#5913) 2025-09-19 19:03:40 +00:00
bw-ghapp[bot]
6f6aacabfb Update SDK to 1.0.0-3101-0eba924a (#5893)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-19 16:21:39 +00:00
David Perez
b0e0b44671 Pm 25258 browser autofill dialog (#5907) 2025-09-19 16:20:16 +00:00
David Perez
d53f3f313c Refactor Folder logic into FolderManager (#5904) 2025-09-19 15:37:31 +00:00
David Perez
4f244c52fa PM-25908: Process 400 responses from verification code APIs (#5900) 2025-09-19 15:29:28 +00:00
Patrick Honkonen
b4a31764c4 [PM-25824] Add "Import items" screen (#5906) 2025-09-19 13:59:26 +00:00
bw-ghapp[bot]
f4569cef2b Crowdin Pull (#5908)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-09-19 13:54:27 +00:00
Patrick Honkonen
b4926b72d9 Update registerExport to return RegisterExportResponse (#5903) 2025-09-18 14:37:14 +00:00
Patrick Honkonen
0f899df83c [PM-25826] Update folderRelationships type for cipher import (#5885) 2025-09-17 21:49:16 +00:00
Amy Galles
12ea84c548 add write permissions 2025-09-17 14:48:29 -07:00
Amy Galles
c13973c22a add specific branch for testing 2025-09-17 14:38:30 -07:00
Amy Galles
d5e9463dfa remove test options 2025-09-17 14:15:12 -07:00
Amy Galles
8006189dba test enable disable again 2025-09-17 14:10:05 -07:00
Amy Galles
e188a8eef8 test enable disable again 2025-09-17 14:08:07 -07:00
Amy Galles
70a266e6c7 test enable disable again 2025-09-17 14:04:46 -07:00
Amy Galles
898ea3c050 add dry run to run name 2025-09-17 14:01:54 -07:00
Amy Galles
f5833eec71 update actions with dry run options and actions permission 2025-09-17 14:00:21 -07:00
Patrick Honkonen
ff03f49f43 [PM-25912] Remove ImportCredentialsRequest (#5901) 2025-09-17 20:42:42 +00:00
David Perez
2756bd9fde Refactor cipher logic into CipherManager (#5898) 2025-09-17 19:51:44 +00:00
Patrick Honkonen
a39f83349f Move NativeLibraryManager to data module (#5899) 2025-09-17 19:21:37 +00:00
Patrick Honkonen
7d3ed2af88 [PM-25822] Add ImportItemsViewModel and related strings (#5882) 2025-09-17 17:58:22 +00:00
David Perez
8de465381e Refactor Send logic into SendManager (#5892) 2025-09-17 14:37:14 +00:00
Amy Galles
0864b2deeb temporarily enable hourly checks for github release 2025-09-16 14:42:59 -07:00
Patrick Honkonen
f22f4399be [PM-25664] Add CredentialExchangeImportManager for CXF payload import (#5872) 2025-09-16 21:30:24 +00:00
David Perez
766e6b1bb9 Update resources to use LocalResources (#5894) 2025-09-16 21:01:45 +00:00
David Perez
0fb364128e Update Androidx libraries to latest versions (#5890) 2025-09-16 21:01:28 +00:00
bw-ghapp[bot]
0cbce39499 Update SDK to 1.0.0-3005-5a722fd2 (#5860)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-16 17:13:26 +00:00
Patrick Honkonen
f954b0b941 Refactor Vault Sync Logic into VaultSyncManager (#5871) 2025-09-16 16:44:52 +00:00
David Perez
cfd0a5b8a5 Update the Protobuf library (#5891) 2025-09-16 16:40:12 +00:00
renovate[bot]
d61e1cb6f1 [deps]: Update actions/setup-java action to v5 (#5880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:48:08 +00:00
renovate[bot]
b31983da8b [deps]: Update actions/checkout action to v5 (#5879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:47:36 +00:00
David Perez
e22d309423 Update navigation libs to latest version (#5889) 2025-09-15 23:32:36 +00:00
Patrick Honkonen
9b53095b5e [PM-15051] Add CredentialExchangeRegistry (#5869) 2025-09-15 21:48:56 +00:00
David Perez
c6814c8870 Update to Kotlin v2.2.20 (#5888) 2025-09-15 21:12:11 +00:00
David Perez
7710ad8a73 Update to AGP v8.13.0 (#5887) 2025-09-15 19:55:17 +00:00
Patrick Honkonen
80b3a7e675 [PM-25663] Introduce CredentialExchangeImporter (#5868) 2025-09-15 19:44:03 +00:00
David Perez
8235045dad PM-24234: Add missing plurals (#5886) 2025-09-15 19:02:34 +00:00
Patrick Honkonen
481a8c8fbc [PM-25662] Add CredentialExchangeCompletionManager (#5867) 2025-09-15 18:36:48 +00:00
renovate[bot]
1dc6ea2227 [deps]: Lock file maintenance (#5881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 16:17:39 +00:00
renovate[bot]
6554234898 [deps]: Update gh minor (#5877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 16:16:55 +00:00
David Perez
e990397b29 Update Robolectric to v4.16 (#5833) 2025-09-15 15:33:36 +00:00
bw-ghapp[bot]
417835ef3f Crowdin Pull (#5874)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-09-15 13:48:03 +00:00
aj-rosado
39a6dd1c4b [PM-22320] Default to SHA1 on 2fas importer if algorithm is missing (#5875) 2025-09-13 08:57:13 +00:00
Patrick Honkonen
4093e61b09 [PM-25665] Add BitwardenImportCredentialsRequest and helper (#5870) 2025-09-11 17:51:57 +00:00
Patrick Honkonen
c4adf3ad42 [PM-25661] Add placeholder ProviderEvents API for credential import/export (#5866) 2025-09-11 14:06:57 +00:00
André Bispo
417a1494e3 [PM-25640] Dialog flickers when switching accounts (#5865) 2025-09-11 13:41:34 +00:00
André Bispo
ef39ea6d5d [PM-25624] Hide decryption errors from autofill list view (#5855) 2025-09-11 13:41:21 +00:00
Patrick Honkonen
f6c20e08d1 [PM-25637] Add CXF module for Credential Exchange support (#5858) 2025-09-11 12:49:06 +00:00
Álison Fernandes
987e065dd7 Fix sdk-update Test by using Java 21 in setup-android action (#5861) 2025-09-10 18:31:37 +00:00
Patrick Honkonen
ba7ee04281 [PM-15056] Add exportVaultDataToCxf function to VaultRepository (#5847) 2025-09-10 14:40:05 +00:00
Konrad
808d57edc5 Update untranslatable strings (#5854) 2025-09-10 13:43:50 +00:00
David Perez
3356925c7a Update to Java 21 (#5835) 2025-09-10 13:41:41 +00:00
bw-ghapp[bot]
0487d95122 Update SDK to 1.0.0-2944-8447df0c (#5830)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-09-10 09:03:31 +00:00
bw-ghapp[bot]
0834a7a883 Crowdin Pull (#5853)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-09-08 16:17:54 +00:00
David Perez
2b0e8f9941 Update appVersionName to 2025.9.1 (#5848) 2025-09-05 21:52:04 +00:00
Patrick Honkonen
0702078b04 [PM-25523] Add importCxfPayload to VaultRepository (#5846) 2025-09-05 19:57:43 +00:00
David Perez
46c7e79039 Cleanup minor lint warnings in string resources (#5843) 2025-09-05 19:56:46 +00:00
David Perez
1d6e733c08 Update protobuff library to v4.32.0 (#5845) 2025-09-05 18:56:12 +00:00
Patrick Honkonen
a298b85374 [PM-25522] Add importCxf function to VaultSdkSource (#5841) 2025-09-05 18:56:01 +00:00
David Perez
fe79ea4822 PM-25162: Fix a navigation bug in bottom navigation (#5842) 2025-09-05 16:38:11 +00:00
Patrick Honkonen
4c50f873e2 [PM-15055] Add SDK support for exporting vault data to CXF (#5840) 2025-09-05 16:29:32 +00:00
451 changed files with 19825 additions and 8138 deletions

6
.github/CODEOWNERS vendored
View File

@@ -48,3 +48,9 @@
# app/src/main/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
# app/src/test/java/com/x8bit/bitwarden/data/vault @bitwarden/team-vault-dev
# app/src/test/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
# Docker-related files
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 17
JAVA_VERSION: 21
permissions:
contents: read
@@ -51,7 +51,7 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
@@ -76,13 +76,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -110,10 +110,10 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -211,7 +211,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}

View File

@@ -28,7 +28,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 17
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
permissions:
@@ -52,7 +52,7 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
@@ -77,13 +77,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -118,10 +118,10 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -206,7 +206,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -429,10 +429,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -503,7 +503,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Download Google Privileged Browsers List
run: curl -s $SOURCE_URL -o $GOOGLE_FILE

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -50,7 +50,7 @@ jobs:
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Download translations
uses: crowdin/github-action@590c05e09a29f392b203faf4d6aa8e0cd32c7835 # v2.9.1
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

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

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
name: Publish to Google Play
run-name: "Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}"
run-name: >
${{ inputs.dry-run && ' (Dry Run)' || '' }}Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}
on:
workflow_dispatch:
inputs:
@@ -46,6 +47,10 @@ on:
- production
- Fastlane Automation Target
required: true
dry-run:
description: "Dry-Run, Run the workflow without publishing to the store"
type: boolean
default: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@@ -54,6 +59,7 @@ permissions:
contents: read
packages: read
id-token: write
actions: write
jobs:
promote:
@@ -71,10 +77,10 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -122,6 +128,8 @@ jobs:
echo "$FORMATTED_MESSAGE" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Promote Play Store version to production
if: ${{ inputs.dry-run == false }}
id: publish
env:
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
@@ -158,3 +166,19 @@ jobs:
releaseNotes:"$RELEASE_NOTES" \
track:"$TRACK_FROM" \
trackPromoteTo:"$TRACK_TARGET"
- name: Enable Publish Github Release Workflow
if: ${{ steps.publish.conclusion == 'success' || inputs.dry-run }}
env:
PRODUCT: ${{ inputs.product }}
DRY_RUN: ${{ inputs.dry-run }}
run: |
if $DRY_RUN ; then
gh workflow view publish-github-release.yml
exit 0
fi
if [ "$PRODUCT" = "Password Manager" ]; then
gh workflow enable publish-github-release-bwpm.yml
elif [ "$PRODUCT" = "Authenticator" ]; then
gh workflow enable publish-github-release-bwa.yml
fi

View File

@@ -22,7 +22,7 @@ jobs:
actions: write
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0

View File

@@ -63,7 +63,7 @@ jobs:
permission-contents: write
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
@@ -203,7 +203,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs

View File

@@ -13,7 +13,7 @@ on:
workflow_dispatch:
env:
_JAVA_VERSION: 17
_JAVA_VERSION: 21
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
@@ -52,12 +52,12 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env._JAVA_VERSION }}
@@ -91,7 +91,7 @@ jobs:
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:

View File

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

View File

@@ -52,12 +52,12 @@
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
4. Setup JDK `Version` `17`:
4. Setup JDK `Version` `21`:
- Navigate to `Preferences > Build, Execution, Deployment > Build Tools > Gradle`.
- Hit the selected Gradle JDK next to `Gradle JDK:`.
- Select a `17.x` version or hit `Download JDK...` if not present.
- Select `Version` `17`.
- Select a `21.x` version or hit `Download JDK...` if not present.
- Select `Version` `21`.
- Select your preferred `Vendor`.
- Hit `Download`.
- Hit `Apply`.

View File

@@ -224,6 +224,7 @@ dependencies {
implementation(project(":annotation"))
implementation(project(":core"))
implementation(project(":cxf"))
implementation(project(":data"))
implementation(project(":network"))
implementation(project(":ui"))
@@ -245,6 +246,8 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.providerevents)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -258,7 +261,6 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.androidx.credentials)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)

View File

@@ -20,6 +20,18 @@
<data android:host="*.bitwarden.pw" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<!-- Handle Credential Exchange transfer requests -->
<intent-filter
android:autoVerify="true"
tools:ignore="AppLinkUrlError">
<action android:name="androidx.identitycredentials.action.IMPORT_CREDENTIALS" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:mimeType="application/octet-stream"
android:scheme="content"
tools:ignore="AppLinkUriRelativeFilterGroupError" />
</intent-filter>
</activity>
</application>

View File

@@ -249,7 +249,7 @@
android:name="com.x8bit.bitwarden.AutofillTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/autofill"
android:label="@string/autofill_title"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>

View File

@@ -5,6 +5,8 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
@@ -295,6 +297,7 @@ class MainViewModel @Inject constructor(
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@@ -418,6 +421,16 @@ class MainViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AccountSecurityShortcut
}
importCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
requestJson = importCredentialsRequest.request.requestJson,
),
)
}
}
}

View File

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

View File

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

View File

@@ -33,6 +33,8 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
import com.bitwarden.network.service.AccountsService
@@ -740,7 +742,17 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onSuccess = {
when (it) {
VerificationCodeResponseJson.Success -> ResendEmailResult.Success
is VerificationCodeResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(
@@ -753,7 +765,17 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendNewDeviceOtp(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onSuccess = {
when (it) {
VerificationOtpResponseJson.Success -> ResendEmailResult.Success
is VerificationOtpResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(

View File

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

View File

@@ -32,6 +32,7 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
val updatedProfile = profile.copy(userDecryptionOptions = updatedUserDecryptionOptions)
val updatedAccount = account.copy(profile = updatedProfile)
@@ -54,6 +55,23 @@ fun UserStateJson.toUpdatedUserStateJson(
val userId = syncProfile.id
val account = this.accounts[userId] ?: return this
val profile = account.profile
val userDecryptionOptions = syncResponse
.userDecryption
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
}
?: profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = null)
val updatedProfile = profile
.copy(
avatarColorHex = syncProfile.avatarColor,
@@ -61,6 +79,7 @@ fun UserStateJson.toUpdatedUserStateJson(
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
@@ -90,6 +109,7 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,6 +105,11 @@ interface SettingsDiskSource {
*/
val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
/**
* The time at which the browser autofill dialog is allowed to be shown to the user again.
*/
var browserAutofillDialogReshowTime: Instant?
/**
* Clears all the settings data for the given user.
*/
@@ -281,6 +286,23 @@ interface SettingsDiskSource {
*/
fun getUserHasSignedInPreviously(userId: String): Boolean
/**
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowBrowserAutofillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable the browser autofill integration in onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?)
/**
* Emits updates that track [getShowAutoFillSettingBadge] for the given [userId].
*/
fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.

View File

@@ -37,6 +37,7 @@ private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "clearClipboard"
private const val INITIAL_AUTOFILL_DIALOG_SHOWN = "addSitePromptShown"
private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedInOrCreatedAccount"
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
private const val SHOW_BROWSER_AUTOFILL_SETTING_BADGE = "showBrowserAutofillSettingBadge"
private const val SHOW_UNLOCK_SETTING_BADGE = "showUnlockSettingBadge"
private const val SHOW_IMPORT_LOGINS_SETTING_BADGE = "showImportLoginsSettingBadge"
private const val IS_VAULT_REGISTERED_FOR_EXPORT = "isVaultRegisteredForExport"
@@ -48,6 +49,7 @@ private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMa
private const val RESUME_SCREEN = "resumeScreen"
private const val FLIGHT_RECORDER_KEY = "flightRecorderData"
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
/**
* Primary implementation of [SettingsDiskSource].
@@ -72,6 +74,9 @@ class SettingsDiskSourceImpl(
private val mutablePullToRefreshEnabledFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableShowBrowserAutofillSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableShowAutoFillSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@@ -224,6 +229,12 @@ class SettingsDiskSourceImpl(
override val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) }
override var browserAutofillDialogReshowTime: Instant?
get() = getLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME)?.let { Instant.ofEpochMilli(it) }
set(value) {
putLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME, value = value?.toEpochMilli())
}
override fun clearData(userId: String) {
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)
storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null)
@@ -431,6 +442,21 @@ class SettingsDiskSourceImpl(
key = HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY.appendIdentifier(userId),
) == true
override fun getShowBrowserAutofillSettingBadge(userId: String): Boolean? =
getBoolean(key = SHOW_BROWSER_AUTOFILL_SETTING_BADGE.appendIdentifier(userId))
override fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?) {
putBoolean(
key = SHOW_BROWSER_AUTOFILL_SETTING_BADGE.appendIdentifier(userId),
value = showBadge,
)
getMutableShowBrowserAutofillSettingBadgeFlow(userId).tryEmit(showBadge)
}
override fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?> =
getMutableShowBrowserAutofillSettingBadgeFlow(userId = userId)
.onSubscription { emit(getShowBrowserAutofillSettingBadge(userId)) }
override fun getShowAutoFillSettingBadge(userId: String): Boolean? =
getBoolean(
key = SHOW_AUTOFILL_SETTING_BADGE.appendIdentifier(userId),
@@ -598,6 +624,13 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowBrowserAutofillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableShowBrowserAutofillSettingBadgeFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowAutoFillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,9 @@ import kotlinx.coroutines.flow.Flow
*/
interface PushManager {
/**
* Flow that represents requests intended for full syncs.
* Flow that represents requests intended for full syncs for the user ID provided.
*/
val fullSyncFlow: Flow<Unit>
val fullSyncFlow: Flow<String>
/**
* Flow that represents requests intended to log a user out.

View File

@@ -55,7 +55,7 @@ class PushManagerImpl @Inject constructor(
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
@@ -73,7 +73,7 @@ class PushManagerImpl @Inject constructor(
private val mutableSyncSendUpsertSharedFlow =
bufferedMutableSharedFlow<SyncSendUpsertData>()
override val fullSyncFlow: SharedFlow<Unit>
override val fullSyncFlow: SharedFlow<String>
get() = mutableFullSyncSharedFlow.asSharedFlow()
override val logoutFlow: SharedFlow<NotificationLogoutData>
@@ -204,7 +204,10 @@ class PushManagerImpl @Inject constructor(
NotificationType.SYNC_SETTINGS,
NotificationType.SYNC_VAULT,
-> {
mutableFullSyncSharedFlow.tryEmit(Unit)
json
.decodeFromString<NotificationPayload.SyncNotification>(notification.payload)
.userId
?.let { mutableFullSyncSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_FOLDER_CREATE,

View File

@@ -1,10 +1,21 @@
package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
/**
* The token provider to pass to the SDK.
*/
class Token : ClientManagedTokens {
override suspend fun getAccessToken(): String? {
return null
}
}
/**
* Primary implementation of [SdkClientManager].
*/
@@ -13,7 +24,7 @@ class SdkClientManagerImpl(
sdkRepoFactory: SdkRepositoryFactory,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
Client(settings = null).apply {
Client(tokenProvider = Token(), settings = null).apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
userId?.let {
platform().state().apply {

View File

@@ -9,6 +9,7 @@ import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.manager.DispatcherManagerImpl
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.EventService
@@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -41,8 +43,6 @@ import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.LogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -242,10 +242,6 @@ object PlatformManagerModule {
)
}
@Provides
@Singleton
fun provideNativeLibraryManager(): NativeLibraryManager = NativeLibraryManagerImpl()
@Provides
@Singleton
fun provideSdkClientManager(
@@ -355,12 +351,14 @@ object PlatformManagerModule {
vaultDiskSource: VaultDiskSource,
dispatcherManager: DispatcherManager,
autofillEnabledManager: AutofillEnabledManager,
thirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
): FirstTimeActionManager = FirstTimeActionManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
vaultDiskSource = vaultDiskSource,
dispatcherManager = dispatcherManager,
autofillEnabledManager = autofillEnabledManager,
thirdPartyAutofillEnabledManager = thirdPartyAutofillEnabledManager,
)
@Provides

View File

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

View File

@@ -83,4 +83,12 @@ sealed class NotificationPayload {
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
/**
* A notification payload for syncing a users vault.
*/
@Serializable
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
}

View File

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

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
@@ -71,3 +72,12 @@ fun SpecialCircumstance.toTotpDataOrNull(): TotpData? =
is SpecialCircumstance.AddTotpLoginItem -> this.data
else -> null
}
/**
* Returns [ImportCredentialsRequestData] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toImportCredentialsRequestDataOrNull(): ImportCredentialsRequestData? =
when (this) {
is SpecialCircumstance.CredentialExchangeExport -> this.data
else -> null
}

View File

@@ -24,6 +24,14 @@ interface VaultDiskSource {
*/
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
*/
suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers from the data source for a given [userId] that contain TOTP codes.
*/

View File

@@ -97,6 +97,24 @@ class VaultDiskSourceImpl(
}
}
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getSelectedCiphers(userId = userId, cipherIds = cipherIds)
return withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Cipher>(
string = entity.cipherJson,
) { Timber.e(it, "Failed to deserialize Cipher in Vault") }
}
}
.awaitAll()
}
}
override suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllTotpCiphers(userId = userId)
return withContext(context = dispatcherManager.default) {

View File

@@ -37,6 +37,15 @@ interface CiphersDao {
userId: String,
): List<CipherEntity>
/**
* Retrieves all ciphers from the database with the given [cipherIds] for a given [userId].
*/
@Query("SELECT * FROM ciphers WHERE user_id = :userId AND id IN (:cipherIds)")
suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<CipherEntity>
/**
* Retrieves all ciphers from the database for a given [userId].
*/

View File

@@ -3,8 +3,8 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory

View File

@@ -9,6 +9,7 @@ import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.exporters.Account
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
@@ -428,6 +429,23 @@ interface VaultSdkSource {
format: ExportFormat,
): Result<String>
/**
* Exports the users vault data to a CXF formatted string.
*/
suspend fun exportVaultDataToCxf(
userId: String,
account: Account,
ciphers: List<Cipher>,
): Result<String>
/**
* Imports the given CXF formatted [payload] into the users vault.
*
* @return Result of the import. If successful, a list of [Cipher]s deciphered from the CXF
* payload.
*/
suspend fun importCxf(userId: String, payload: String): Result<List<Cipher>>
/**
* Register a new FIDO 2 credential to a cipher.
*

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DeriveKeyConnectorException
import com.bitwarden.core.DeriveKeyConnectorRequest
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
@@ -10,6 +11,7 @@ import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.exporters.Account
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
@@ -92,14 +94,17 @@ class VaultSdkSourceImpl(
),
)
DeriveKeyConnectorResult.Success(key)
} catch (exception: BitwardenException) {
when {
exception.message == "Wrong password" -> {
} catch (ex: BitwardenException.DeriveKeyConnector) {
when (ex.v1) {
is DeriveKeyConnectorException.WrongPassword -> {
DeriveKeyConnectorResult.WrongPasswordError
}
else -> DeriveKeyConnectorResult.Error(exception)
is DeriveKeyConnectorException.Crypto -> {
DeriveKeyConnectorResult.Error(error = ex)
}
}
} catch (exception: BitwardenException) {
DeriveKeyConnectorResult.Error(error = exception)
}
}
@@ -473,7 +478,7 @@ class VaultSdkSourceImpl(
): Result<UpdatePasswordResponse> = runCatchingWithLogs {
getClient(userId = userId)
.crypto()
.updatePassword(newPassword = newPassword)
.makeUpdatePassword(newPassword = newPassword)
}
override suspend fun exportVaultDataToString(
@@ -491,6 +496,28 @@ class VaultSdkSourceImpl(
)
}
override suspend fun exportVaultDataToCxf(
userId: String,
account: Account,
ciphers: List<Cipher>,
): Result<String> = runCatchingWithLogs {
getClient(userId = userId)
.exporters()
.exportCxf(
account = account,
ciphers = ciphers,
)
}
override suspend fun importCxf(
userId: String,
payload: String,
): Result<List<Cipher>> = runCatchingWithLogs {
getClient(userId = userId)
.exporters()
.importCxf(payload = payload)
}
override suspend fun registerFido2Credential(
request: RegisterFido2CredentialRequest,
fido2CredentialStore: Fido2CredentialStore,

View File

@@ -31,7 +31,7 @@ fun PublicKeyCredentialAuthenticatorAttestationResponse.toAndroidAttestationResp
.ClientExtensionResults
.CredentialProperties(residentKey = residentKey),
)
},
} ?: Fido2AttestationResponse.ClientExtensionResults(),
authenticatorAttachment = authenticatorAttachment,
)

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toUri
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
@@ -17,7 +18,10 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.EncryptionContext
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
@@ -34,13 +38,18 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toNetworkAttachmentRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import retrofit2.HttpException
import java.io.File
import java.time.Clock
/**
* The default implementation of the [CipherManager].
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
class CipherManagerImpl(
private val fileManager: FileManager,
private val authDiskSource: AuthDiskSource,
@@ -49,9 +58,24 @@ class CipherManagerImpl(
private val vaultSdkSource: VaultSdkSource,
private val clock: Clock,
private val reviewPromptManager: ReviewPromptManager,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
) : CipherManager {
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
pushManager
.syncCipherDeleteFlow
.onEach(::deleteCipher)
.launchIn(unconfinedScope)
pushManager
.syncCipherUpsertFlow
.onEach(::syncCipherIfNecessary)
.launchIn(ioScope)
}
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(
@@ -641,4 +665,79 @@ class CipherManagerImpl(
}
return migratedCipherView.asSuccess()
}
/**
* Deletes the cipher specified by [syncCipherDeleteData] from disk.
*/
private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) {
vaultDiskSource.deleteCipher(
userId = syncCipherDeleteData.userId,
cipherId = syncCipherDeleteData.cipherId,
)
}
/**
* Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria
* are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk
* for now.
*/
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
val userId = activeUserId ?: return
val cipherId = syncCipherUpsertData.cipherId
val organizationId = syncCipherUpsertData.organizationId
val collectionIds = syncCipherUpsertData.collectionIds
val revisionDate = syncCipherUpsertData.revisionDate
val isUpdate = syncCipherUpsertData.isUpdate
// Return if local cipher is more recent
val localCipher = vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
if (localCipher != null &&
localCipher.revisionDate.toEpochSecond() > revisionDate.toEpochSecond()
) {
return
}
var shouldUpdate: Boolean
val shouldCheckCollections: Boolean
when {
isUpdate -> {
shouldUpdate = localCipher != null
shouldCheckCollections = true
}
collectionIds == null || organizationId == null -> {
shouldUpdate = localCipher == null
shouldCheckCollections = false
}
else -> {
shouldUpdate = false
shouldCheckCollections = true
}
}
if (!shouldUpdate && shouldCheckCollections && organizationId != null) {
// Check if there are any collections in common
shouldUpdate = vaultDiskSource
.getCollections(userId = userId)
.first()
.any { collectionIds?.contains(it.id) == true }
}
if (!shouldUpdate) return
ciphersService
.getCipher(cipherId = cipherId)
.fold(
onSuccess = { vaultDiskSource.saveCipher(userId = userId, cipher = it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
},
)
}
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.vault.manager
import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult
/**
* Manages the import process for Credential Exchange Format (CXF) payloads.
*
* This interface provides a contract for importing credential data from a standardized
* CXF string, associating it with a specific user. It handles the parsing, decryption,
* and storage of the credentials contained within the payload.
*/
interface CredentialExchangeImportManager {
/**
* Attempt to import a CXF payload.
*
* @param payload The CXF payload to import.
*/
suspend fun importCxfPayload(userId: String, payload: String): ImportCxfPayloadResult
}

View File

@@ -0,0 +1,76 @@
package com.x8bit.bitwarden.data.vault.manager
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.ImportCiphersResponseJson
import com.bitwarden.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
/**
* Default implementation of [CredentialExchangeImportManager].
*/
class CredentialExchangeImportManagerImpl(
private val vaultSdkSource: VaultSdkSource,
private val ciphersService: CiphersService,
private val vaultSyncManager: VaultSyncManager,
) : CredentialExchangeImportManager {
override suspend fun importCxfPayload(
userId: String,
payload: String,
): ImportCxfPayloadResult = vaultSdkSource
.importCxf(
userId = userId,
payload = payload,
)
.flatMap { cipherList ->
if (cipherList.isEmpty()) {
// If no ciphers were returned, we can skip the remaining steps and return the
// appropriate result.
return ImportCxfPayloadResult.NoItems
}
ciphersService
.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = cipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = cipherList.size)
.asSuccess()
}
}
}
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
}
}
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
}

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
/**
* Manages the creating, updating, and deleting folders.
*/
interface FolderManager {
/**
* Attempt to create a folder.
*/
suspend fun createFolder(folderView: FolderView): CreateFolderResult
/**
* Attempt to delete a folder.
*/
suspend fun deleteFolder(folderId: String): DeleteFolderResult
/**
* Attempt to update a folder.
*/
suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult
}

View File

@@ -0,0 +1,170 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.UpdateFolderResponseJson
import com.bitwarden.network.service.FolderService
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* The default implementation of the [FolderManager].
*/
class FolderManagerImpl(
private val authDiskSource: AuthDiskSource,
private val folderService: FolderService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
) : FolderManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
pushManager
.syncFolderDeleteFlow
.onEach(::deleteFolder)
.launchIn(unconfinedScope)
pushManager
.syncFolderUpsertFlow
.onEach(::syncFolderIfNecessary)
.launchIn(ioScope)
}
override suspend fun createFolder(folderView: FolderView): CreateFolderResult {
val userId = activeUserId ?: return CreateFolderResult.Error(NoActiveUserException())
return vaultSdkSource
.encryptFolder(userId = userId, folder = folderView)
.flatMap { folderService.createFolder(body = it.toEncryptedNetworkFolder()) }
.onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) }
.flatMap {
vaultSdkSource.decryptFolder(userId = userId, folder = it.toEncryptedSdkFolder())
}
.fold(
onSuccess = { CreateFolderResult.Success(folderView = it) },
onFailure = { CreateFolderResult.Error(error = it) },
)
}
override suspend fun deleteFolder(folderId: String): DeleteFolderResult {
val userId = activeUserId ?: return DeleteFolderResult.Error(NoActiveUserException())
return folderService
.deleteFolder(folderId = folderId)
.onSuccess {
clearFolderIdFromCiphers(userId = userId, folderId = folderId)
vaultDiskSource.deleteFolder(userId = userId, folderId = folderId)
}
.fold(
onSuccess = { DeleteFolderResult.Success },
onFailure = { DeleteFolderResult.Error(error = it) },
)
}
override suspend fun updateFolder(
folderId: String,
folderView: FolderView,
): UpdateFolderResult {
val userId = activeUserId ?: return UpdateFolderResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptFolder(userId = userId, folder = folderView)
.flatMap { folder ->
folderService.updateFolder(
folderId = folder.id.toString(),
body = folder.toEncryptedNetworkFolder(),
)
}
.fold(
onSuccess = { response ->
when (response) {
is UpdateFolderResponseJson.Success -> {
vaultDiskSource.saveFolder(userId = userId, folder = response.folder)
vaultSdkSource
.decryptFolder(
userId = userId,
folder = response.folder.toEncryptedSdkFolder(),
)
.fold(
onSuccess = { UpdateFolderResult.Success(it) },
onFailure = {
UpdateFolderResult.Error(errorMessage = null, error = it)
},
)
}
is UpdateFolderResponseJson.Invalid -> {
UpdateFolderResult.Error(errorMessage = response.message, error = null)
}
}
},
onFailure = { UpdateFolderResult.Error(it.message, error = it) },
)
}
private suspend fun clearFolderIdFromCiphers(userId: String, folderId: String) {
vaultDiskSource.getCiphers(userId = userId).forEach {
if (it.folderId == folderId) {
vaultDiskSource.saveCipher(userId = userId, cipher = it.copy(folderId = null))
}
}
}
/**
* Deletes the folder specified by [syncFolderDeleteData] from disk.
*/
private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) {
clearFolderIdFromCiphers(
folderId = syncFolderDeleteData.folderId,
userId = syncFolderDeleteData.userId,
)
vaultDiskSource.deleteFolder(
folderId = syncFolderDeleteData.folderId,
userId = syncFolderDeleteData.userId,
)
}
/**
* Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria
* are met.
*/
private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) {
val userId = activeUserId ?: return
val folderId = syncFolderUpsertData.folderId
val isUpdate = syncFolderUpsertData.isUpdate
val revisionDate = syncFolderUpsertData.revisionDate
val localFolder = vaultDiskSource
.getFolders(userId = userId)
.first()
.find { it.id == folderId }
val isValidCreate = !isUpdate && localFolder == null
val isValidUpdate = isUpdate &&
localFolder != null &&
localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
folderService
.getFolder(folderId = folderId)
.onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) }
}
}

View File

@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
/**
* Manages the creating, updating, and deleting sends.
*/
interface SendManager {
/**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to delete a send.
*/
suspend fun deleteSend(sendId: String): DeleteSendResult
/**
* Attempt to remove the password from a send.
*/
suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult
/**
* Attempt to update a send.
*/
suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult
}

View File

@@ -0,0 +1,296 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.service.SendsService
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import retrofit2.HttpException
/**
* The default implementation of the [SendManager].
*/
@Suppress("LongParameterList")
class SendManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val sendsService: SendsService,
private val fileManager: FileManager,
private val reviewPromptManager: ReviewPromptManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : SendManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
pushManager
.syncSendDeleteFlow
.onEach(::deleteSend)
.launchIn(unconfinedScope)
pushManager
.syncSendUpsertFlow
.onEach(::syncSendIfNecessary)
.launchIn(ioScope)
}
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = activeUserId
?: return CreateSendResult.Error(message = null, error = NoActiveUserException())
return vaultSdkSource
.encryptSend(userId = userId, sendView = sendView)
.flatMap { send ->
when (send.type) {
SendType.TEXT -> sendsService.createTextSend(send.toEncryptedNetworkSend())
SendType.FILE -> createFileSend(uri = fileUri, userId = userId, send = send)
}
}
.map { createSendResponse ->
when (createSendResponse) {
is CreateSendJsonResponse.Invalid -> {
return CreateSendResult.Error(
message = createSendResponse.firstValidationErrorMessage,
error = null,
)
}
is CreateSendJsonResponse.Success -> {
// Save the send immediately, regardless of whether the decrypt succeeds
vaultDiskSource.saveSend(userId = userId, send = createSendResponse.send)
createSendResponse
}
}
}
.flatMap { createSendSuccessResponse ->
vaultSdkSource.decryptSend(
userId = userId,
send = createSendSuccessResponse.send.toEncryptedSdkSend(),
)
}
.fold(
onFailure = { CreateSendResult.Error(message = null, error = it) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(sendView = it)
},
)
}
override suspend fun deleteSend(sendId: String): DeleteSendResult {
val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException())
return sendsService
.deleteSend(sendId)
.onSuccess { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) }
.fold(
onSuccess = { DeleteSendResult.Success },
onFailure = { DeleteSendResult.Error(error = it) },
)
}
override suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult {
val userId = activeUserId ?: return RemovePasswordSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return sendsService
.removeSendPassword(sendId = sendId)
.fold(
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
RemovePasswordSendResult.Error(
errorMessage = response.message,
error = null,
)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { RemovePasswordSendResult.Success(sendView = it) },
onFailure = {
RemovePasswordSendResult.Error(
errorMessage = null,
error = it,
)
},
)
}
}
},
onFailure = { RemovePasswordSendResult.Error(errorMessage = null, error = it) },
)
}
override suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult {
val userId = activeUserId ?: return UpdateSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptSend(userId = userId, sendView = sendView)
.flatMap { send ->
sendsService.updateSend(sendId = sendId, body = send.toEncryptedNetworkSend())
}
.fold(
onFailure = { UpdateSendResult.Error(errorMessage = null, error = it) },
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
UpdateSendResult.Error(errorMessage = response.message, error = null)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { UpdateSendResult.Success(sendView = it) },
onFailure = {
UpdateSendResult.Error(errorMessage = null, error = it)
},
)
}
}
},
)
}
private suspend fun createFileSend(
uri: Uri?,
userId: String,
send: Send,
): Result<CreateSendJsonResponse> {
uri ?: return IllegalArgumentException("File URI must be present to create a File Send.")
.asFailure()
return fileManager
.writeUriToCache(uri)
.flatMap { file ->
vaultSdkSource.encryptFile(
userId = userId,
send = send,
path = file.absolutePath,
destinationFilePath = file.absolutePath,
)
}
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(fileLength = encryptedFile.length()),
)
.flatMap { sendFileResponse ->
when (sendFileResponse) {
is CreateFileSendResponse.Invalid -> {
CreateSendJsonResponse
.Invalid(
message = sendFileResponse.message,
validationErrors = sendFileResponse.validationErrors,
)
.asSuccess()
}
is CreateFileSendResponse.Success -> {
sendsService
.uploadFile(
sendFileResponse = sendFileResponse.createFileJsonResponse,
encryptedFile = encryptedFile,
)
.also {
// Delete encrypted file once it has been uploaded.
fileManager.delete(encryptedFile)
}
.map { CreateSendJsonResponse.Success(it) }
}
}
}
}
}
/**
* Deletes the send specified by [syncSendDeleteData] from disk.
*/
private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) {
vaultDiskSource.deleteSend(
userId = syncSendDeleteData.userId,
sendId = syncSendDeleteData.sendId,
)
}
/**
* Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are
* met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for
* now.
*/
private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
val userId = activeUserId ?: return
val sendId = syncSendUpsertData.sendId
val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = vaultDiskSource
.getSends(userId = userId)
.first()
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
sendsService
.getSend(sendId = sendId)
.fold(
onSuccess = { vaultDiskSource.saveSend(userId = userId, send = it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
},
)
}
}

View File

@@ -0,0 +1,84 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the synchronization of the user's vault data with the remote server.
* This interface provides a way to trigger a sync process, which updates the local
* database with the latest changes from the server.
*/
interface VaultSyncManager {
/**
* Flow that represents the current vault data.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents all ciphers for the active user, including references to ciphers that
* cannot be decrypted.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val decryptCipherListResultStateFlow: StateFlow<DataState<DecryptCipherListResult>>
/**
* Flow that represents all collections for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val collectionsStateFlow: StateFlow<DataState<List<CollectionView>>>
/**
* Flow that represents all domains for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val domainsStateFlow: StateFlow<DataState<DomainsData>>
/**
* Flow that represents all folders for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val foldersStateFlow: StateFlow<DataState<List<FolderView>>>
/**
* Flow that represents the current send data.
*/
val sendDataStateFlow: StateFlow<DataState<SendData>>
/**
* Sync the vault data for the current user.
*
* Unlike [syncIfNecessary], this will always perform the requested sync and should only be
* utilized in cases where the user specifically requested the action.
*/
fun sync(forced: Boolean = false)
/**
* Checks if conditions have been met to perform a sync request and, if so, syncs the vault
* data for the current user.
*/
fun syncIfNecessary()
/**
* Syncs the vault data for the current user. This is an explicit request to sync and will
* return the result of the sync as a [SyncVaultDataResult].
*/
suspend fun syncForResult(forced: Boolean = false): SyncVaultDataResult
}

View File

@@ -0,0 +1,539 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.repository.util.map
import com.bitwarden.core.data.repository.util.updateToPendingOrLoading
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.service.SyncService
import com.bitwarden.network.util.isNoConnectionError
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.error.SecurityStampMismatchException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabeticallyByTypeAndOrganization
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.temporal.ChronoUnit
/**
* A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the
* specified period of time after it no longer has subscribers.
*/
private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L
private const val SYNC_IF_NECESSARY_DELAY_MIN: Long = 30L
/**
* Default implementation of [VaultSyncManager].
*/
@Suppress("LongParameterList", "TooManyFunctions")
class VaultSyncManagerImpl(
private val syncService: SyncService,
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val userLogoutManager: UserLogoutManager,
private val vaultLockManager: VaultLockManager,
private val clock: Clock,
databaseSchemeManager: DatabaseSchemeManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : VaultSyncManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
private var syncJob: Job = Job().apply { complete() }
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
private val mutableSendDataStateFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val mutableDecryptCipherListResultFlow =
MutableStateFlow<DataState<DecryptCipherListResult>>(DataState.Loading)
private val mutableFoldersStateFlow =
MutableStateFlow<DataState<List<FolderView>>>(DataState.Loading)
private val mutableCollectionsStateFlow =
MutableStateFlow<DataState<List<CollectionView>>>(DataState.Loading)
private val mutableDomainsStateFlow =
MutableStateFlow<DataState<DomainsData>>(DataState.Loading)
override val decryptCipherListResultStateFlow: StateFlow<DataState<DecryptCipherListResult>>
get() = mutableDecryptCipherListResultFlow.asStateFlow()
override val domainsStateFlow: StateFlow<DataState<DomainsData>>
get() = mutableDomainsStateFlow.asStateFlow()
override val foldersStateFlow: StateFlow<DataState<List<FolderView>>>
get() = mutableFoldersStateFlow.asStateFlow()
override val collectionsStateFlow: StateFlow<DataState<List<CollectionView>>>
get() = mutableCollectionsStateFlow.asStateFlow()
override val sendDataStateFlow: StateFlow<DataState<SendData>>
get() = mutableSendDataStateFlow.asStateFlow()
override val vaultDataStateFlow: StateFlow<DataState<VaultData>> =
combine(
decryptCipherListResultStateFlow,
foldersStateFlow,
collectionsStateFlow,
sendDataStateFlow,
) { ciphersDataState, foldersDataState, collectionsDataState, sendsDataState ->
combineDataStates(
ciphersDataState,
foldersDataState,
collectionsDataState,
sendsDataState,
) { ciphersData, foldersData, collectionsData, sendsData ->
VaultData(
decryptCipherListResult = ciphersData,
collectionViewList = collectionsData,
folderViewList = foldersData,
sendViewList = sendsData.sendViewList,
)
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = STOP_TIMEOUT_DELAY_MS),
initialValue = DataState.Loading,
)
init {
// Cancel any ongoing sync request and clear the vault data in memory every time
// the user switches or the vault is locked for the active user.
merge(
authDiskSource
.userSwitchingChangesFlow
.onEach {
// DomainState is not part of the locked data but should still be cleared
// when the user changes
mutableDomainsStateFlow.update { DataState.Loading }
},
vaultLockManager
.vaultUnlockDataStateFlow
.filter { vaultUnlockDataList ->
// Clear if the active user is not currently unlocking or unlocked
vaultUnlockDataList.none { it.userId == activeUserId }
},
)
.onEach {
syncJob.cancel()
clearUnlockedData()
}
.launchIn(unconfinedScope)
// Setup ciphers MutableStateFlow
mutableDecryptCipherListResultFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId -> observeVaultDiskCiphersToCipherListView(userId = activeUserId) }
.launchIn(unconfinedScope)
// Setup domains MutableStateFlow
mutableDomainsStateFlow
.observeWhenSubscribedAndLoggedIn(
userStateFlow = authDiskSource.userStateFlow,
) { activeUserId -> observeVaultDiskDomains(userId = activeUserId) }
.launchIn(unconfinedScope)
// Setup folders MutableStateFlow
mutableFoldersStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId -> observeVaultDiskFolders(userId = activeUserId) }
.launchIn(unconfinedScope)
// Setup collections MutableStateFlow
mutableCollectionsStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId -> observeVaultDiskCollections(userId = activeUserId) }
.launchIn(unconfinedScope)
// Setup sends MutableStateFlow
mutableSendDataStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId -> observeVaultDiskSends(userId = activeUserId) }
.launchIn(unconfinedScope)
pushManager
.fullSyncFlow
.onEach { userId ->
if (userId == activeUserId) {
sync(forced = false)
} else {
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
}
}
.launchIn(unconfinedScope)
databaseSchemeManager
.databaseSchemeChangeFlow
.onEach { sync(forced = true) }
.launchIn(ioScope)
}
override fun sync(forced: Boolean) {
val userId = activeUserId ?: return
if (!syncJob.isCompleted) return
mutableDecryptCipherListResultFlow.updateToPendingOrLoading()
mutableDomainsStateFlow.updateToPendingOrLoading()
mutableFoldersStateFlow.updateToPendingOrLoading()
mutableCollectionsStateFlow.updateToPendingOrLoading()
mutableSendDataStateFlow.updateToPendingOrLoading()
syncJob = ioScope.launch { syncInternal(userId = userId, forced = forced) }
}
override fun syncIfNecessary() {
val userId = activeUserId ?: return
// Sync if we have never done so or the last time was at last 30 minutes ago.
val shouldSync = settingsDiskSource
.getLastSyncTime(userId = userId)
?.let {
clock.instant().isAfter(it.plus(SYNC_IF_NECESSARY_DELAY_MIN, ChronoUnit.MINUTES))
}
?: true
if (shouldSync) {
sync(forced = false)
}
}
override suspend fun syncForResult(forced: Boolean): SyncVaultDataResult {
val userId = activeUserId ?: return SyncVaultDataResult.Error(NoActiveUserException())
syncJob = ioScope
.async { syncInternal(userId = userId, forced = forced) }
.also {
return try {
it.await()
} catch (e: CancellationException) {
SyncVaultDataResult.Error(throwable = e)
}
}
}
@Suppress("LongMethod")
private suspend fun syncInternal(
userId: String,
forced: Boolean,
): SyncVaultDataResult {
if (!forced) {
// Skip this check if we are forcing the request.
val lastSyncInstant = settingsDiskSource
.getLastSyncTime(userId = userId)
?.toEpochMilli()
lastSyncInstant?.let { lastSyncTimeMs ->
// If the lasSyncState is null we just sync, no checks required.
syncService.getAccountRevisionDateMillis().fold(
onSuccess = { serverRevisionDate ->
if (serverRevisionDate < lastSyncTimeMs) {
// We can skip the actual sync call if there is no new data or
// database scheme changes since the last sync.
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
vaultDiskSource.resyncVaultData(userId = userId)
val itemsAvailable = vaultDiskSource
.getCiphersFlow(userId)
.firstOrNull()
?.isNotEmpty() == true
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
}
},
onFailure = {
updateVaultStateFlowsToError(throwable = it)
return SyncVaultDataResult.Error(throwable = it)
},
)
}
}
return syncService.sync().fold(
onSuccess = { syncResponse ->
val localSecurityStamp = authDiskSource.userState?.activeAccount?.profile?.stamp
val serverSecurityStamp = syncResponse.profile.securityStamp
// Log the user out if the stamps do not match
localSecurityStamp?.let {
if (serverSecurityStamp != localSecurityStamp) {
// Ensure UserLogoutManager is available
userLogoutManager.softLogout(
userId = userId,
reason = LogoutReason.SecurityStamp,
)
return SyncVaultDataResult.Error(SecurityStampMismatchException())
}
}
// Update user information with additional information from sync response
authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson(
syncResponse = syncResponse,
)
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
storeProfileData(syncResponse = syncResponse)
// Treat absent network policies as known empty data to
// distinguish between unknown null data.
authDiskSource.storePolicies(
userId = userId,
policies = syncResponse.policies.orEmpty(),
)
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true
SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
},
onFailure = {
updateVaultStateFlowsToError(throwable = it)
SyncVaultDataResult.Error(throwable = it)
},
)
}
private suspend fun unlockVaultForOrganizationsIfNecessary(
syncResponse: SyncResponseJson,
) {
val profile = syncResponse.profile
val organizationKeys = profile.organizations
.orEmpty()
.filter { it.key != null }
.associate { it.id to requireNotNull(it.key) }
.takeUnless { it.isEmpty() }
?: return
// There shouldn't be issues when unlocking directly from the syncResponse so we can ignore
// the return type here.
vaultSdkSource.initializeOrganizationCrypto(
userId = syncResponse.profile.id,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
}
private fun storeProfileData(
syncResponse: SyncResponseJson,
) {
val profile = syncResponse.profile
val userId = profile.id
authDiskSource.apply {
storeUserKey(userId = userId, userKey = profile.key)
storePrivateKey(userId = userId, privateKey = profile.privateKey)
storeAccountKeys(userId = userId, accountKeys = profile.accountKeys)
storeOrganizationKeys(
userId = userId,
organizationKeys = profile.organizations
.orEmpty()
.filter { it.key != null }
.associate { it.id to requireNotNull(it.key) },
)
storeShouldUseKeyConnector(
userId = userId,
shouldUseKeyConnector = profile.shouldUseKeyConnector,
)
storeOrganizations(userId = userId, organizations = profile.organizations)
}
}
private fun observeVaultDiskCiphersToCipherListView(
userId: String,
): Flow<DataState<DecryptCipherListResult>> =
vaultDiskSource
.getCiphersFlow(userId = userId)
.onStart { mutableDecryptCipherListResultFlow.updateToPendingOrLoading() }
.map {
vaultLockManager.waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherListWithFailures(
userId = userId,
cipherList = it.toEncryptedSdkCipherList(),
)
.fold(
onSuccess = { result ->
DataState.Loaded(
result.copy(successes = result.successes.sortAlphabetically()),
)
},
onFailure = { throwable -> DataState.Error(error = throwable) },
)
}
.map {
it
.takeUnless { settingsDiskSource.getLastSyncTime(userId = userId) == null }
?: DataState.Loading
}
.onEach { mutableDecryptCipherListResultFlow.value = it }
private fun observeVaultDiskDomains(
userId: String,
): Flow<DataState<DomainsData>> =
vaultDiskSource
.getDomains(userId = userId)
.onStart { mutableDomainsStateFlow.updateToPendingOrLoading() }
.map { DataState.Loaded(data = it.toDomainsData()) }
.onEach { mutableDomainsStateFlow.value = it }
private fun observeVaultDiskFolders(
userId: String,
): Flow<DataState<List<FolderView>>> =
vaultDiskSource
.getFolders(userId = userId)
.onStart { mutableFoldersStateFlow.updateToPendingOrLoading() }
.map {
vaultLockManager.waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptFolderList(userId = userId, folderList = it.toEncryptedSdkFolderList())
.fold(
onSuccess = { folders -> DataState.Loaded(folders.sortAlphabetically()) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableFoldersStateFlow.value = it }
private fun observeVaultDiskCollections(
userId: String,
): Flow<DataState<List<CollectionView>>> =
vaultDiskSource
.getCollections(userId = userId)
.onStart { mutableCollectionsStateFlow.updateToPendingOrLoading() }
.map {
vaultLockManager.waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCollectionList(
userId = userId,
collectionList = it.toEncryptedSdkCollectionList(),
)
.fold(
onSuccess = { collections ->
DataState.Loaded(
data = collections.sortAlphabeticallyByTypeAndOrganization(
userOrganizations = authDiskSource
.getOrganizations(userId = userId)
.orEmpty(),
),
)
},
onFailure = { throwable -> DataState.Error(error = throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableCollectionsStateFlow.value = it }
private fun observeVaultDiskSends(
userId: String,
): Flow<DataState<SendData>> =
vaultDiskSource
.getSends(userId = userId)
.onStart { mutableSendDataStateFlow.updateToPendingOrLoading() }
.map {
vaultLockManager.waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptSendList(userId = userId, sendList = it.toEncryptedSdkSendList())
.fold(
onSuccess = { sends -> DataState.Loaded(sends.sortAlphabetically()) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.map { dataState -> dataState.map { SendData(sendViewList = it) } }
.onEach { mutableSendDataStateFlow.value = it }
private fun clearUnlockedData() {
mutableDecryptCipherListResultFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
mutableCollectionsStateFlow.update { DataState.Loading }
mutableSendDataStateFlow.update { DataState.Loading }
}
private fun updateVaultStateFlowsToError(throwable: Throwable) {
mutableDecryptCipherListResultFlow.update { currentState ->
throwable.toNetworkOrErrorState(data = currentState.data)
}
mutableDomainsStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(data = currentState.data)
}
mutableFoldersStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(data = currentState.data)
}
mutableCollectionsStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(data = currentState.data)
}
mutableSendDataStateFlow.update { currentState ->
throwable.toNetworkOrErrorState(data = currentState.data)
}
}
/**
* Returns the given [DataState] as-is, or [DataState.Loading] if vault data for the given
* [userId] has not synced. This can be used to distinguish between empty data in the database
* because we are in the process of syncing from legitimately having no vault data.
*/
private fun <T> DataState<List<T>>.orLoadingIfNotSynced(
userId: String,
): DataState<List<T>> =
this
.takeUnless { settingsDiskSource.getLastSyncTime(userId = userId) == null }
?: DataState.Loading
}
private fun <T> Throwable.toNetworkOrErrorState(
data: T?,
): DataState<T> =
if (isNoConnectionError()) {
DataState.NoNetwork(data = data)
} else {
DataState.Error(error = this, data = data)
}

View File

@@ -5,23 +5,37 @@ import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CipherManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.FolderManagerImpl
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.SendManagerImpl
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -47,6 +61,8 @@ object VaultManagerModule {
fileManager: FileManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
): CipherManager = CipherManagerImpl(
fileManager = fileManager,
authDiskSource = authDiskSource,
@@ -55,6 +71,48 @@ object VaultManagerModule {
vaultSdkSource = vaultSdkSource,
clock = clock,
reviewPromptManager = reviewPromptManager,
dispatcherManager = dispatcherManager,
pushManager = pushManager,
)
@Provides
@Singleton
fun provideFolderManager(
folderService: FolderService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
): FolderManager = FolderManagerImpl(
authDiskSource = authDiskSource,
folderService = folderService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager,
pushManager = pushManager,
)
@Provides
@Singleton
fun provideSendManager(
sendsService: SendsService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
fileManager: FileManager,
reviewPromptManager: ReviewPromptManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
): SendManager = SendManagerImpl(
fileManager = fileManager,
authDiskSource = authDiskSource,
sendsService = sendsService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
reviewPromptManager = reviewPromptManager,
pushManager = pushManager,
dispatcherManager = dispatcherManager,
)
@Provides
@@ -110,4 +168,44 @@ object VaultManagerModule {
dispatcherManager = dispatcherManager,
clock = clock,
)
@Provides
@Singleton
fun provideVaultSyncManager(
syncService: SyncService,
settingsDiskSource: SettingsDiskSource,
authDiskSource: AuthDiskSource,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
userLogoutManager: UserLogoutManager,
vaultLockManager: VaultLockManager,
clock: Clock,
databaseSchemeManager: DatabaseSchemeManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
): VaultSyncManager = VaultSyncManagerImpl(
syncService = syncService,
settingsDiskSource = settingsDiskSource,
authDiskSource = authDiskSource,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
userLogoutManager = userLogoutManager,
vaultLockManager = vaultLockManager,
clock = clock,
databaseSchemeManager = databaseSchemeManager,
pushManager = pushManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideCredentialExchangeImportManager(
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
vaultSyncManager: VaultSyncManager,
): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
)
}

View File

@@ -0,0 +1,29 @@
package com.x8bit.bitwarden.data.vault.manager.model
/**
* Models result of the vault data being imported from a CXF payload.
*/
sealed class ImportCxfPayloadResult {
/**
* The vault data has been successfully imported.
*/
data class Success(val itemCount: Int) : ImportCxfPayloadResult()
/**
* There are no items to import.
*/
data object NoItems : ImportCxfPayloadResult()
/**
* The sync process has failed after importing the CXF payload.
*/
data class SyncFailed(val error: Throwable) : ImportCxfPayloadResult()
/**
* There was an error importing the vault data.
*
* @param error The error that occurred during import.
*/
data class Error(val error: Throwable) : ImportCxfPayloadResult()
}

View File

@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.vault.repository.model
package com.x8bit.bitwarden.data.vault.manager.model
/**
* Represents the result of a sync operation.
@@ -14,7 +14,7 @@ sealed class SyncVaultDataResult {
/**
* Indicates a failed sync operation.
*
* @property throwable The exception that caused the failure, if any.
* @property throwable The exception that caused the failure.
*/
data class Error(val throwable: Throwable?) : SyncVaultDataResult()
data class Error(val throwable: Throwable) : SyncVaultDataResult()
}

View File

@@ -1,36 +1,25 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import kotlinx.coroutines.flow.Flow
@@ -41,7 +30,12 @@ import javax.crypto.Cipher
* Responsible for managing vault data inside the network layer.
*/
@Suppress("TooManyFunctions")
interface VaultRepository : CipherManager, VaultLockManager {
interface VaultRepository :
CipherManager,
FolderManager,
SendManager,
VaultLockManager,
VaultSyncManager {
/**
* The [VaultFilterType] for the current user.
@@ -51,52 +45,6 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
var vaultFilterType: VaultFilterType
/**
* Flow that represents the current vault data.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents all ciphers for the active user, including references to ciphers that
* cannot be decrypted.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val decryptCipherListResultStateFlow: StateFlow<DataState<DecryptCipherListResult>>
/**
* Flow that represents all collections for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val collectionsStateFlow: StateFlow<DataState<List<CollectionView>>>
/**
* Flow that represents all domains for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val domainsStateFlow: StateFlow<DataState<DomainsData>>
/**
* Flow that represents all folders for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val foldersStateFlow: StateFlow<DataState<List<FolderView>>>
/**
* Flow that represents the current send data.
*/
val sendDataStateFlow: StateFlow<DataState<SendData>>
/**
* Flow that represents the totp code.
*/
@@ -107,26 +55,6 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
fun deleteVaultData(userId: String)
/**
* Sync the vault data for the current user.
*
* Unlike [syncIfNecessary], this will always perform the requested sync and should only be
* utilized in cases where the user specifically requested the action.
*/
fun sync(forced: Boolean = false)
/**
* Checks if conditions have been met to perform a sync request and, if so, syncs the vault
* data for the current user.
*/
fun syncIfNecessary()
/**
* Syncs the vault data for the current user. This is an explicit request to sync and will
* return the result of the sync as a [SyncVaultDataResult].
*/
suspend fun syncForResult(): SyncVaultDataResult
/**
* Flow that represents the data for a specific vault item as found by ID. This may emit `null`
* if the item cannot be found.
@@ -203,50 +131,11 @@ interface VaultRepository : CipherManager, VaultLockManager {
pin: String,
): VaultUnlockResult
/**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to update a send.
*/
suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult
/**
* Attempt to remove the password from a send.
*/
suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult
/**
* Attempt to get the verification code and the period.
*/
suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult
/**
* Attempt to delete a send.
*/
suspend fun deleteSend(sendId: String): DeleteSendResult
/**
* Attempt to create a folder.
*/
suspend fun createFolder(folderView: FolderView): CreateFolderResult
/**
* Attempt to delete a folder.
*/
suspend fun deleteFolder(folderId: String): DeleteFolderResult
/**
* Attempt to update a folder.
*/
suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult
/**
* Attempt to get the user's vault data for export.
*
@@ -258,6 +147,20 @@ interface VaultRepository : CipherManager, VaultLockManager {
restrictedTypes: List<CipherType>,
): ExportVaultDataResult
/**
* Attempt to import a CXF payload.
*
* @param payload The CXF payload to import.
*/
suspend fun importCxfPayload(payload: String): ImportCredentialsResult
/**
* Attempt to export the vault data to a CXF file.
*
* @param ciphers Ciphers selected for export.
*/
suspend fun exportVaultDataToCxf(ciphers: List<CipherListView>): Result<String>
/**
* Flow that represents the data for a specific vault list item as found by ID. This may emit
* `null` if the item cannot be found.

View File

@@ -1,29 +1,22 @@
package com.x8bit.bitwarden.data.vault.repository.di
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepositoryImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@@ -36,42 +29,28 @@ object VaultRepositoryModule {
@Provides
@Singleton
fun providesVaultRepository(
syncService: SyncService,
sendsService: SendsService,
ciphersService: CiphersService,
folderService: FolderService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
cipherManager: CipherManager,
fileManager: FileManager,
folderManager: FolderManager,
sendManager: SendManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
totpCodeManager: TotpCodeManager,
pushManager: PushManager,
userLogoutManager: UserLogoutManager,
databaseSchemeManager: DatabaseSchemeManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
vaultSyncManager: VaultSyncManager,
credentialExchangeImportManager: CredentialExchangeImportManager,
): VaultRepository = VaultRepositoryImpl(
syncService = syncService,
sendsService = sendsService,
ciphersService = ciphersService,
folderService = folderService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
cipherManager = cipherManager,
fileManager = fileManager,
folderManager = folderManager,
sendManager = sendManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
totpCodeManager = totpCodeManager,
pushManager = pushManager,
userLogoutManager = userLogoutManager,
databaseSchemeManager = databaseSchemeManager,
clock = clock,
reviewPromptManager = reviewPromptManager,
vaultSyncManager = vaultSyncManager,
credentialExchangeImportManager = credentialExchangeImportManager,
)
}

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Represents the result of importing credentials from a credential manager.
*/
sealed class ImportCredentialsResult {
/**
* Indicates the vault data has been successfully imported.
*/
data class Success(val itemCount: Int) : ImportCredentialsResult()
/**
* Indicates there are no items to import.
*/
data object NoItems : ImportCredentialsResult()
/**
* Indicates the vault data has been successfully uploaded, but there was an error syncing the
* vault data.
*/
data class SyncFailed(val error: Throwable) : ImportCredentialsResult()
/**
* Indicates there was an error importing the vault data.
*
* @param error The error that occurred during import.
*/
data class Error(val error: Throwable) : ImportCredentialsResult()
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.exporters.Account
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
/**
* Converts a [AccountJson] to a [Account] for use in the SDK.
*/
fun AccountJson.toSdkAccount(): Account = Account(
id = profile.userId,
email = profile.email,
name = profile.name,
)

View File

@@ -106,6 +106,7 @@ fun Cipher.toEncryptedNetworkCipherResponse(
shouldViewPassword = viewPassword,
key = key,
encryptedFor = encryptedFor,
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
@@ -389,6 +390,7 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
creationDate = creationDate.toInstant(),
deletedDate = deletedDate?.toInstant(),
revisionDate = revisionDate.toInstant(),
archivedDate = archivedDate?.toInstant(),
)
/**
@@ -704,4 +706,5 @@ fun Cipher.toFailureCipherListView(): CipherListView =
deletedDate = deletedDate,
revisionDate = revisionDate,
copyableFields = emptyList(),
archivedDate = archivedDate,
)

View File

@@ -87,9 +87,18 @@ fun NavController.navigateToSetupAutoFillAsRootScreen(navOptions: NavOptions? =
/**
* Add the setup autofill screen to the nav graph.
*/
fun NavGraphBuilder.setupAutoFillDestination(onNavigateBack: () -> Unit) {
fun NavGraphBuilder.setupAutoFillDestination(
onNavigateBack: () -> Unit,
onNavigateToBrowserAutofill: () -> Unit,
) {
composableWithSlideTransitions<SetupAutofillRoute.Standard> {
SetupAutoFillScreen(onNavigateBack = onNavigateBack)
SetupAutoFillScreen(
onNavigateBack = onNavigateBack,
onNavigateToBrowserAutofill = {
onNavigateBack()
onNavigateToBrowserAutofill()
},
)
}
}
@@ -102,6 +111,9 @@ fun NavGraphBuilder.setupAutoFillDestinationAsRoot() {
onNavigateBack = {
// No-Op
},
onNavigateToBrowserAutofill = {
// No-Op
},
)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -27,6 +28,7 @@ class SetupAutoFillViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val authRepository: AuthRepository,
private val firstTimeActionManager: FirstTimeActionManager,
private val browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
) :
BaseViewModel<SetupAutoFillState, SetupAutoFillEvent, SetupAutoFillAction>(
// We load the state from the savedStateHandle for testing purposes.
@@ -100,15 +102,22 @@ class SetupAutoFillViewModel @Inject constructor(
private fun handleTurnOnLaterConfirmClick() {
firstTimeActionManager.storeShowAutoFillSettingBadge(showBadge = true)
updateOnboardingStatusToFinalStep()
updateOnboardingStatusToNextStep()
}
private fun handleContinueClick() {
firstTimeActionManager.storeShowAutoFillSettingBadge(showBadge = false)
if (state.isInitialSetup) {
updateOnboardingStatusToFinalStep()
updateOnboardingStatusToNextStep()
} else {
sendEvent(SetupAutoFillEvent.NavigateBack)
val isBrowserAutofillUnconfigured = browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.isAnyIsAvailableAndDisabled
if (isBrowserAutofillUnconfigured) {
sendEvent(SetupAutoFillEvent.NavigateToBrowserAutofill)
} else {
sendEvent(SetupAutoFillEvent.NavigateBack)
}
}
}
@@ -120,10 +129,18 @@ class SetupAutoFillViewModel @Inject constructor(
}
}
private fun updateOnboardingStatusToFinalStep() =
authRepository.setOnboardingStatus(
status = OnboardingStatus.FINAL_STEP,
)
private fun updateOnboardingStatusToNextStep() {
val isAutofillEnabled = settingsRepository.isAutofillEnabledStateFlow.value
val isBrowserAutofillUnconfigured = browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.isAnyIsAvailableAndDisabled
val nextStep = when {
!isAutofillEnabled -> OnboardingStatus.FINAL_STEP
isBrowserAutofillUnconfigured -> OnboardingStatus.BROWSER_AUTOFILL_SETUP
else -> OnboardingStatus.FINAL_STEP
}
authRepository.setOnboardingStatus(status = nextStep)
}
}
/**
@@ -164,6 +181,11 @@ sealed class SetupAutoFillEvent {
*/
data object NavigateToAutofillSettings : SetupAutoFillEvent()
/**
* Navigate to the setup browser autofill screen.
*/
data object NavigateToBrowserAutofill : SetupAutoFillEvent()
/**
* Navigate back.
*/

View File

@@ -27,14 +27,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.image.BitwardenGifImage
@@ -60,6 +60,7 @@ import com.x8bit.bitwarden.ui.platform.manager.utils.startSystemAutofillSettings
@Composable
fun SetupAutoFillScreen(
onNavigateBack: () -> Unit,
onNavigateToBrowserAutofill: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: SetupAutoFillViewModel = hiltViewModel(),
) {
@@ -75,6 +76,7 @@ fun SetupAutoFillScreen(
}
SetupAutoFillEvent.NavigateBack -> onNavigateBack()
SetupAutoFillEvent.NavigateToBrowserAutofill -> onNavigateToBrowserAutofill()
}
}
when (state.dialogState) {
@@ -114,7 +116,7 @@ fun SetupAutoFillScreen(
id = if (state.isInitialSetup) {
BitwardenString.account_setup
} else {
BitwardenString.turn_on_autofill
BitwardenString.autofill_setup
},
),
scrollBehavior = scrollBehavior,
@@ -182,13 +184,14 @@ private fun SetupAutoFillContent(
BitwardenFilledButton(
label = stringResource(id = BitwardenString.continue_text),
onClick = onContinueClick,
isEnabled = state.autofillEnabled,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
if (state.isInitialSetup) {
BitwardenTextButton(
BitwardenOutlinedButton(
label = stringResource(BitwardenString.turn_on_later),
onClick = onTurnOnLaterClick,
modifier = Modifier

View File

@@ -0,0 +1,111 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* The type-safe route for the setup browser autofill screen.
*/
@Parcelize
@Serializable(with = SetupBrowserAutofillRoute.Serializer::class)
sealed class SetupBrowserAutofillRoute : Parcelable {
/**
* The [isInitialSetup] value used in the setup browser autofill screen.
*/
abstract val isInitialSetup: Boolean
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<SetupBrowserAutofillRoute>(
kClass = SetupBrowserAutofillRoute::class,
)
/**
* The type-safe route for the standard setup browser autofill screen.
*/
@Parcelize
@Serializable(with = Standard.Serializer::class)
data object Standard : SetupBrowserAutofillRoute() {
override val isInitialSetup: Boolean get() = false
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<Standard>(Standard::class)
}
/**
* The type-safe route for the root setup browser autofill screen.
*/
@Parcelize
@Serializable(with = AsRoot.Serializer::class)
data object AsRoot : SetupBrowserAutofillRoute() {
override val isInitialSetup: Boolean get() = true
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<AsRoot>(AsRoot::class)
}
}
/**
* Arguments for the [SetupBrowserAutofillScreen] using [SavedStateHandle].
*/
data class SetupBrowserAutofillScreenArgs(val isInitialSetup: Boolean)
/**
* Constructs a [SetupAutoFillScreenArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toSetupBrowserAutofillArgs(): SetupBrowserAutofillScreenArgs {
val route = this.toRoute<SetupBrowserAutofillRoute>()
return SetupBrowserAutofillScreenArgs(isInitialSetup = route.isInitialSetup)
}
/**
* Navigate to the setup browser autofill screen.
*/
fun NavController.navigateToSetupBrowserAutofillScreen(navOptions: NavOptions? = null) {
this.navigate(route = SetupBrowserAutofillRoute.Standard, navOptions = navOptions)
}
/**
* Navigate to the setup browser autofill screen as the root.
*/
fun NavController.navigateToSetupBrowserAutoFillAsRootScreen(navOptions: NavOptions? = null) {
this.navigate(route = SetupBrowserAutofillRoute.AsRoot, navOptions = navOptions)
}
/**
* Add the setup browser autofill screen to the nav graph.
*/
fun NavGraphBuilder.setupBrowserAutofillDestination(onNavigateBack: () -> Unit) {
composableWithSlideTransitions<SetupBrowserAutofillRoute.Standard> {
SetupBrowserAutofillScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Add the setup browser autofill screen to the nav graph as a root.
*/
fun NavGraphBuilder.setupBrowserAutofillDestinationAsRoot() {
composableWithPushTransitions<SetupBrowserAutofillRoute.AsRoot> {
SetupBrowserAutofillScreen(
onNavigateBack = {
// No-Op
},
)
}
}

View File

@@ -0,0 +1,254 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.BrowserAutofillSettingsCard
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.model.BrowserAutofillSettingsOption
import com.x8bit.bitwarden.ui.platform.manager.utils.startBrowserAutofillSettingsActivity
import kotlinx.collections.immutable.persistentListOf
/**
* Top level composable for the Setup Browser Autofill screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun SetupBrowserAutofillScreen(
viewModel: SetupBrowserAutofillViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SetupBrowserAutofillEvent.NavigateBack -> onNavigateBack()
is SetupBrowserAutofillEvent.NavigateToBrowserAutofillSettings -> {
intentManager.startBrowserAutofillSettingsActivity(
browserPackage = event.browserPackage,
)
}
SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo -> {
intentManager.launchUri(
"https://bitwarden.com/help/auto-fill-android/#browser-integrations/".toUri(),
)
}
}
}
SetupBrowserAutofillDialogs(
dialogState = state.dialogState,
onDismissDialog = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.DismissDialog) }
},
onTurnOnLaterConfirm = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.TurnOnLaterConfirmClick) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(
id = if (state.isInitialSetup) {
BitwardenString.account_setup
} else {
BitwardenString.autofill_setup
},
),
scrollBehavior = scrollBehavior,
navigationIcon = if (state.isInitialSetup) {
null
} else {
NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.CloseClick) }
},
)
},
)
},
) {
SetupBrowserAutofillContent(
state = state,
onWhyIsThisStepRequiredClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.WhyIsThisStepRequiredClick) }
},
onBrowserClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.BrowserIntegrationClick(it)) }
},
onContinueClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.ContinueClick) }
},
onTurnOnLaterClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.TurnOnLaterClick) }
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Suppress("LongMethod")
@Composable
private fun SetupBrowserAutofillContent(
state: SetupBrowserAutofillState,
onWhyIsThisStepRequiredClick: () -> Unit,
onBrowserClick: (BrowserPackage) -> Unit,
onContinueClick: () -> Unit,
onTurnOnLaterClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(height = 24.dp))
Text(
text = stringResource(id = BitwardenString.turn_on_browser_autofill_integration),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
Text(
text = pluralStringResource(
id = BitwardenPlurals.youre_using_a_browser_that_requires_special_permissions,
count = state.browserCount,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenClickableText(
label = stringResource(id = BitwardenString.why_is_this_step_required),
style = BitwardenTheme.typography.labelMedium,
onClick = onWhyIsThisStepRequiredClick,
modifier = Modifier
.wrapContentWidth()
.align(alignment = Alignment.CenterHorizontally)
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BrowserAutofillSettingsCard(
options = state.browserAutofillSettingsOptions,
onOptionClicked = onBrowserClick,
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.continue_text),
onClick = onContinueClick,
isEnabled = state.isContinueEnabled,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
if (state.isInitialSetup) {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(BitwardenString.turn_on_later),
onClick = onTurnOnLaterClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun SetupBrowserAutofillDialogs(
dialogState: SetupBrowserAutofillState.DialogState?,
onTurnOnLaterConfirm: () -> Unit,
onDismissDialog: () -> Unit,
) {
when (dialogState) {
SetupBrowserAutofillState.DialogState.TurnOnLaterDialog -> {
BitwardenTwoButtonDialog(
title = stringResource(BitwardenString.turn_on_browser_autofill_integration_later),
message = stringResource(
id = BitwardenString.return_to_complete_this_step_anytime_in_settings,
),
confirmButtonText = stringResource(id = BitwardenString.confirm),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onTurnOnLaterConfirm,
onDismissClick = onDismissDialog,
onDismissRequest = onDismissDialog,
)
}
null -> Unit
}
}
@Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SetupBrowserAutofillContent_preview() {
BitwardenTheme {
SetupBrowserAutofillContent(
state = SetupBrowserAutofillState(
dialogState = null,
isInitialSetup = true,
browserAutofillSettingsOptions = persistentListOf(
BrowserAutofillSettingsOption.BraveStable(enabled = true),
BrowserAutofillSettingsOption.ChromeStable(enabled = false),
BrowserAutofillSettingsOption.ChromeBeta(enabled = true),
),
),
onWhyIsThisStepRequiredClick = { },
onBrowserClick = { },
onContinueClick = { },
onTurnOnLaterClick = { },
)
}
}

View File

@@ -0,0 +1,237 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.model.BrowserAutofillSettingsOption
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.util.toBrowserAutoFillSettingsOptions
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the Setup Browser Autofill screen.
*/
@HiltViewModel
class SetupBrowserAutofillViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val firstTimeActionManager: FirstTimeActionManager,
browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SetupBrowserAutofillState, SetupBrowserAutofillEvent, SetupBrowserAutofillAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: SetupBrowserAutofillState(
dialogState = null,
isInitialSetup = savedStateHandle.toSetupBrowserAutofillArgs().isInitialSetup,
browserAutofillSettingsOptions = browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.toBrowserAutoFillSettingsOptions(),
),
) {
init {
browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatusFlow
.map(SetupBrowserAutofillAction.Internal::BrowserAutofillStatusReceive)
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: SetupBrowserAutofillAction) {
when (action) {
is SetupBrowserAutofillAction.BrowserIntegrationClick -> {
handleBrowserIntegrationClick(action)
}
SetupBrowserAutofillAction.WhyIsThisStepRequiredClick -> {
handleWhyIsThisStepRequiredClick()
}
SetupBrowserAutofillAction.CloseClick -> handleCloseClick()
SetupBrowserAutofillAction.DismissDialog -> handleDismissDialog()
SetupBrowserAutofillAction.ContinueClick -> handleContinueClick()
SetupBrowserAutofillAction.TurnOnLaterClick -> handleTurnOnLaterClick()
SetupBrowserAutofillAction.TurnOnLaterConfirmClick -> handleTurnOnLaterConfirmClick()
is SetupBrowserAutofillAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: SetupBrowserAutofillAction.Internal) {
when (action) {
is SetupBrowserAutofillAction.Internal.BrowserAutofillStatusReceive -> {
handleBrowserAutofillStatusReceive(action)
}
}
}
private fun handleBrowserIntegrationClick(
action: SetupBrowserAutofillAction.BrowserIntegrationClick,
) {
sendEvent(
SetupBrowserAutofillEvent.NavigateToBrowserAutofillSettings(action.browserPackage),
)
}
private fun handleWhyIsThisStepRequiredClick() {
sendEvent(SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo)
}
private fun handleCloseClick() {
sendEvent(SetupBrowserAutofillEvent.NavigateBack)
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleContinueClick() {
firstTimeActionManager.storeShowBrowserAutofillSettingBadge(showBadge = false)
if (state.isInitialSetup) {
authRepository.setOnboardingStatus(status = OnboardingStatus.FINAL_STEP)
} else {
sendEvent(SetupBrowserAutofillEvent.NavigateBack)
}
}
private fun handleTurnOnLaterClick() {
mutableStateFlow.update {
it.copy(dialogState = SetupBrowserAutofillState.DialogState.TurnOnLaterDialog)
}
}
private fun handleTurnOnLaterConfirmClick() {
firstTimeActionManager.storeShowBrowserAutofillSettingBadge(showBadge = true)
mutableStateFlow.update { it.copy(dialogState = null) }
authRepository.setOnboardingStatus(status = OnboardingStatus.FINAL_STEP)
}
private fun handleBrowserAutofillStatusReceive(
action: SetupBrowserAutofillAction.Internal.BrowserAutofillStatusReceive,
) {
mutableStateFlow.update {
it.copy(
browserAutofillSettingsOptions = action.status.toBrowserAutoFillSettingsOptions(),
)
}
}
}
/**
* UI State for the Setup Browser Autofill screen.
*/
@Parcelize
data class SetupBrowserAutofillState(
val dialogState: DialogState?,
val isInitialSetup: Boolean,
val browserAutofillSettingsOptions: ImmutableList<BrowserAutofillSettingsOption>,
) : Parcelable {
/**
* The number of browsers that can be configured.
*/
val browserCount: Int get() = browserAutofillSettingsOptions.size
/**
* Indicates if the Continue button should be enabled or not.
*/
val isContinueEnabled: Boolean get() = browserAutofillSettingsOptions.all { it.isEnabled }
/**
* Models dialogs that can be shown on the Setup Browser Autofill screen.
*/
@Parcelize
sealed class DialogState : Parcelable {
/**
* Represents the turn on later dialog.
*/
data object TurnOnLaterDialog : DialogState()
}
}
/**
* UI Events for the Setup Browser Autofill screen.
*/
sealed class SetupBrowserAutofillEvent {
/**
* Navigates back.
*/
data object NavigateBack : SetupBrowserAutofillEvent()
/**
* Navigate to the Autofill settings of the specified [browserPackage].
*/
data class NavigateToBrowserAutofillSettings(
val browserPackage: BrowserPackage,
) : SetupBrowserAutofillEvent()
/**
* Navigates to the browser integrations info page.
*/
data object NavigateToBrowserIntegrationsInfo : SetupBrowserAutofillEvent()
}
/**
* UI Actions for the Setup Browser Autofill screen.
*/
sealed class SetupBrowserAutofillAction {
/**
* Indicates that a browser integration toggle was clicked.
*/
data class BrowserIntegrationClick(
val browserPackage: BrowserPackage,
) : SetupBrowserAutofillAction()
/**
* Indicates that the close button has been clicked.
*/
data object CloseClick : SetupBrowserAutofillAction()
/**
* Indicates that the dialog has been dismissed.
*/
data object DismissDialog : SetupBrowserAutofillAction()
/**
* Indicates that the "Continue" button was clicked.
*/
data object ContinueClick : SetupBrowserAutofillAction()
/**
* Indicates that the "Turn on later" button was clicked.
*/
data object TurnOnLaterClick : SetupBrowserAutofillAction()
/**
* Indicates that the confirmation button was clicked to turn on later.
*/
data object TurnOnLaterConfirmClick : SetupBrowserAutofillAction()
/**
* Indicates that the "Why is this step required?" button was clicked.
*/
data object WhyIsThisStepRequiredClick : SetupBrowserAutofillAction()
/**
* Models actions the [SetupBrowserAutofillViewModel] itself may send.
*/
sealed class Internal : SetupBrowserAutofillAction() {
/**
* Received updated [BrowserThirdPartyAutofillStatus] data.
*/
data class BrowserAutofillStatusReceive(
val status: BrowserThirdPartyAutofillStatus,
) : Internal()
}
}

View File

@@ -23,7 +23,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton

View File

@@ -31,14 +31,14 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@@ -218,11 +218,11 @@ private fun SetUpLaterButton(
) {
var displayConfirmation by rememberSaveable { mutableStateOf(value = false) }
if (displayConfirmation) {
@Suppress("MaxLineLength")
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.set_up_unlock_later),
message = stringResource(
id = BitwardenString.you_can_return_to_complete_this_step_anytime_from_account_security_in_settings,
id = BitwardenString
.you_can_return_to_complete_this_step_anytime_from_account_security_in_settings,
),
confirmButtonText = stringResource(id = BitwardenString.confirm),
dismissButtonText = stringResource(id = BitwardenString.cancel),
@@ -235,7 +235,7 @@ private fun SetUpLaterButton(
)
}
BitwardenTextButton(
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.set_up_later),
onClick = { displayConfirmation = true },
modifier = modifier.testTag(tag = "SetUpLaterButton"),

View File

@@ -9,6 +9,7 @@ import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -34,6 +35,7 @@ class SetupUnlockViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val firstTimeActionManager: FirstTimeActionManager,
private val browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
) : BaseViewModel<SetupUnlockState, SetupUnlockEvent, SetupUnlockAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -203,10 +205,14 @@ class SetupUnlockViewModel @Inject constructor(
}
private fun updateOnboardingStatusToNextStep() {
val nextStep = if (settingsRepository.isAutofillEnabledStateFlow.value) {
OnboardingStatus.FINAL_STEP
} else {
OnboardingStatus.AUTOFILL_SETUP
val isAutofillEnabled = settingsRepository.isAutofillEnabledStateFlow.value
val isBrowserAutofillUnconfigured = browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.isAnyIsAvailableAndDisabled
val nextStep = when {
!isAutofillEnabled -> OnboardingStatus.AUTOFILL_SETUP
isBrowserAutofillUnconfigured -> OnboardingStatus.BROWSER_AUTOFILL_SETUP
else -> OnboardingStatus.FINAL_STEP
}
authRepository.setOnboardingStatus(nextStep)
}

View File

@@ -28,7 +28,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.annotatedStringResource

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -26,14 +25,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
@@ -48,6 +46,9 @@ import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
@@ -74,16 +75,15 @@ fun CompleteRegistrationScreen(
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = rememberCompleteRegistrationHandler(viewModel = viewModel)
val context = LocalContext.current
val snackbarHostState = rememberBitwardenSnackbarHostState()
// route OS back actions through the VM to clear the special circumstance
BackHandler(onBack = handler.onBackClick)
EventsEffect(viewModel) { event ->
when (event) {
is CompleteRegistrationEvent.NavigateBack -> onNavigateBack.invoke()
is CompleteRegistrationEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
is CompleteRegistrationEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(BitwardenSnackbarData(message = event.message))
}
CompleteRegistrationEvent.NavigateToMakePasswordStrong -> onNavigateToPasswordGuidance()
@@ -143,6 +143,7 @@ fun CompleteRegistrationScreen(
onNavigationIconClick = handler.onBackClick,
)
},
snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) },
) {
Column(
modifier = Modifier

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.isValidEmail
import com.bitwarden.ui.platform.resource.BitwardenPlurals
@@ -54,6 +55,7 @@ class CompleteRegistrationViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val toastManager: ToastManager,
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toCompleteRegistrationArgs()
@@ -146,9 +148,7 @@ class CompleteRegistrationViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
CompleteRegistrationEvent.ShowToast(
message = BitwardenString.email_verified.asText(),
),
CompleteRegistrationEvent.ShowSnackbar(BitwardenString.email_verified.asText()),
)
}
}
@@ -243,11 +243,7 @@ class CompleteRegistrationViewModel @Inject constructor(
private fun handleLoginResult(action: Internal.ReceiveLoginResult) {
clearDialogState()
sendEvent(
CompleteRegistrationEvent.ShowToast(
message = BitwardenString.account_created_success.asText(),
),
)
toastManager.show(messageId = BitwardenString.account_created_success)
authRepository.setOnboardingStatus(
status = OnboardingStatus.NOT_STARTED,
@@ -504,9 +500,9 @@ sealed class CompleteRegistrationEvent {
data object NavigateBack : CompleteRegistrationEvent()
/**
* Show a toast with the given message.
* Show a snackbar with the given message.
*/
data class ShowToast(
data class ShowSnackbar(
val message: Text,
) : CompleteRegistrationEvent()

View File

@@ -22,7 +22,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -23,7 +23,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -19,7 +19,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar

View File

@@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.util.EventsEffect

View File

@@ -28,7 +28,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar

View File

@@ -24,7 +24,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar

View File

@@ -26,7 +26,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -23,7 +23,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar

View File

@@ -36,7 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.util.EventsEffect

View File

@@ -24,7 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin

View File

@@ -32,7 +32,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.network.model.TwoFactorAuthMethod

View File

@@ -407,7 +407,8 @@ class TwoFactorLoginViewModel @Inject constructor(
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.verification_email_not_sent.asText(),
message = result.message?.asText()
?: BitwardenString.verification_email_not_sent.asText(),
error = result.error,
),
)

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