Compare commits

...

105 Commits

Author SHA1 Message Date
Patrick Honkonen
1c525b9dfc [PM-24575] Add feature flag for AEAD enrollment on key rotation (#5665) 2025-08-08 14:43:34 +00:00
Álison Fernandes
c613c2df86 [PM-24564] Address GitHub Release creation workflow feedback (#5666) 2025-08-08 12:38:34 +00:00
Álison Fernandes
9a9125321e [PM-24589] Trigger CI builds for release branches (#5667) 2025-08-08 12:35:19 +00:00
bw-ghapp[bot]
2902b89402 Crowdin Pull (#5664)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-08-08 01:56:34 +00:00
Patrick Honkonen
93edbb61bf [PM-24411] Add MIME type parameter to file chooser intent (#5661) 2025-08-07 17:55:50 +00:00
David Perez
85bc76d0a6 PM-24565: Syncronize token refreshes to avoid duplicate requests (#5662) 2025-08-07 17:54:22 +00:00
Patrick Honkonen
db18e8012a [PM-24411] Use shareErrorReport in BitwardenBasicDialog (#5656) 2025-08-07 16:13:59 +00:00
Patrick Honkonen
db03c7d703 Refactor Autofill Hint Logic and Add Card Autofill Support (#5640) 2025-08-07 14:47:53 +00:00
Matt Andreko
6ee7f9b80f Update scan workflow to use centralized reusable component (#5592) 2025-08-07 14:26:49 +00:00
David Perez
fc88ca1ba8 PM-24539: Prevent token refresh from looping (#5658) 2025-08-07 13:53:56 +00:00
David Perez
3c033d4aa2 PM-24481: Logout when token refresh API returns 401 or 403 (#5651) 2025-08-06 20:38:01 +00:00
Patrick Honkonen
59c2261e7c [PM-24411] Add shareErrorReport to IntentManager (#5655) 2025-08-06 20:25:40 +00:00
Patrick Honkonen
b6aa0952b1 Set base.archivesName for app and authenticator modules (#5657) 2025-08-06 20:25:26 +00:00
Patrick Honkonen
905e3248f2 [PM-24411] Introduce BuildInfoManager for build-related information (#5654) 2025-08-06 18:53:03 +00:00
David Perez
72250dce90 [PM-24481] Update AuthTokenInterceptor to refresh token on expiration (#5647) 2025-08-06 18:05:07 +00:00
Carlos Gonçalves
60ee129e0b [PM-24456] Update bitwarden sdk to 1.0.0-2450-9fe3aeda (#5652) 2025-08-06 15:50:27 +00:00
André Bispo
911bb40be8 [PM-24473] Remove exemption from restrict item types policy (#5646) 2025-08-05 22:15:40 +00:00
David Perez
308a8a564c Update to Gradle v9.0.0 (#5642) 2025-08-05 21:31:44 +00:00
David Perez
337e751c05 Move FileData to 'ui' module (#5644) 2025-08-05 18:01:36 +00:00
Patrick Honkonen
f4c4e06dcc [PM-24411] Extract pending intent management for Credential Manager requests (#5636) 2025-08-05 14:04:12 +00:00
David Perez
38b92133ff PM-24440: Log user out for 'invalid_grant' (#5641) 2025-08-04 19:13:44 +00:00
renovate[bot]
e381d72d5c [deps]: Update gh minor (#5631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 18:39:11 +00:00
David Perez
87a61bbbbd Update to AGP 8.12.0 (#5639) 2025-08-04 15:23:40 +00:00
David Perez
7cc3c1c755 Handle tile intents without IntentManager (#5635) 2025-08-01 20:31:25 +00:00
David Perez
f614d6039f Commonize version name and bump it (#5559) 2025-08-01 16:58:55 +00:00
renovate[bot]
a6d622c3b9 [deps]: Lock file maintenance (#5632)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 16:39:48 +00:00
David Perez
b781acb1fa Update Androidx dependencies to the latest versions (#5630) 2025-08-01 16:19:27 +00:00
mKoonrad
45f0ddc60f [PM-24292] Correct redundant string interpolation (#5614)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-08-01 15:28:14 +00:00
David Perez
8876418177 Add fingerprint to flight recorder (#5625) 2025-08-01 15:07:00 +00:00
David Perez
67b64034ff Update Junit to v5.13.4 (#5624) 2025-08-01 15:02:05 +00:00
bw-ghapp[bot]
79a232919a Crowdin Pull (#5628)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-08-01 14:34:06 +00:00
David Perez
2fa9ea18b5 PM-15229: Accomidate system bars on specific Android 15 revisions (#5617) 2025-07-30 21:07:06 +00:00
Patrick Honkonen
9b297286e5 [PM-24112] Remove Password Manager strings and translations (#5590) 2025-07-30 20:51:04 +00:00
David Perez
376a62edaf Fix lint warnings and imports (#5623) 2025-07-30 20:32:18 +00:00
Patrick Honkonen
01570c2555 [PM-24113] Remove Authenticator strings and translations (#5589) 2025-07-30 20:06:30 +00:00
Patrick Honkonen
4a3db4fea7 [PM-24175] Refactor Crowdin workflow (#5587) 2025-07-30 18:03:13 +00:00
David Perez
3c0818232f Add logging for Biometric errors (#5621) 2025-07-30 16:56:29 +00:00
David Perez
1799d0b716 PM-24303: Master password reprompt fix (#5620) 2025-07-30 16:27:29 +00:00
Patrick Honkonen
cf896d6bf1 [PM-24206] Fix filtered verification code search (#5619) 2025-07-30 15:34:57 +00:00
Patrick Honkonen
40dff74d3f [PM-22814] Migrate BitwardenCard to the ui module (#5615) 2025-07-30 00:27:26 +00:00
David Perez
ddd2d7fad5 PM-24275: Move content package to 'ui' module (#5613) 2025-07-29 17:45:10 +00:00
David Perez
b4efc0e59d PM-24267: Move indicators to 'ui' module (#5612) 2025-07-29 16:30:04 +00:00
David Perez
4ffd41c33f PM-24245: Remove the restrict-item-deletion-to-can-manage-permission feature flag (#5606) 2025-07-29 14:31:13 +00:00
David Perez
a70f441064 PM-24240: Remove email verification feature flag (#5605) 2025-07-28 20:45:45 +00:00
Carlos Gonçalves
867e2287dc [PM-24157] Update Bitwarden SDK to 1.0.0-20250728.143558-250 (#5602) 2025-07-28 20:25:13 +00:00
Patrick Honkonen
912f734cae [PM-24205] Fix Fido2CredentialStore to save new credentials correctly (#5601) 2025-07-28 19:13:09 +00:00
Patrick Honkonen
02b5cbb199 [PM-24204] Correct TOTP generation to use cipherId instead of totpCode (#5599) 2025-07-28 18:45:37 +00:00
David Perez
f589546e6a PM-24176: Consolidate all FlagKeys (#5593) 2025-07-28 18:05:55 +00:00
David Perez
517198b265 Fix crash in Android 13 (#5588) 2025-07-25 18:42:38 +00:00
David Perez
91f1180be7 PM-20150, PM-20151: Remove single tap passkey feature flags (#5585) 2025-07-25 18:05:18 +00:00
bw-ghapp[bot]
8589a37e5a Crowdin Pull - Password Manager (#5586)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-25 17:00:45 +00:00
bw-ghapp[bot]
e4678cc7df Crowdin Pull - Authenticator (#5584)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-25 15:35:34 +00:00
David Perez
e665c386ff PM-20152: Remove import logins flow feature flag (#5580) 2025-07-25 14:14:48 +00:00
bw-ghapp[bot]
2f2ec71fc4 Crowdin Pull - Authenticator (#5581)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-25 14:13:00 +00:00
bw-ghapp[bot]
7b115df83a Crowdin Pull - Password Manager (#5582)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-25 14:12:53 +00:00
bw-ghapp[bot]
edd1763198 Crowdin Pull - Password Manager (#5578)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-24 21:11:13 +00:00
Patrick Honkonen
37d3ff30e4 [PM-24002] Copy Authenticator strings to ui module (#5576) 2025-07-24 21:10:58 +00:00
David Perez
258a58aa25 PM-24137, PM-24138: Remove host alias feature flags (#5575) 2025-07-24 20:46:46 +00:00
Patrick Honkonen
da5dcef41e [PM-24111] Copy Password Manager strings to ui module (#5569) 2025-07-24 19:30:05 +00:00
David Perez
7a578ff2c5 Update the version name to 2025.7.0 (#5572) 2025-07-24 16:34:18 +00:00
Nailik
355facc36b [PM-13789] add credential manager provider for passwords (#4110)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
Co-authored-by: Patrick Honkonen <rizzin@gmail.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-07-24 15:29:55 +00:00
David Perez
c60f3131b6 PM-24090: Remove ChromeAutofill feature flag (#5567) 2025-07-24 14:49:02 +00:00
David Perez
bb950c8c59 PM-24089: Remove Mutual TLS feature flag (#5566) 2025-07-24 13:33:32 +00:00
David Perez
c7df80ff00 PM-24088: Remove the MobileErrorReporting feature flag (#5565) 2025-07-24 13:33:22 +00:00
David Perez
d308b84943 PM-24087: Update the add/edit ssh key title (#5564) 2025-07-23 21:06:44 +00:00
David Perez
79ad18877d Update Androidx and Hilt dependencies (#5563) 2025-07-23 20:05:36 +00:00
David Perez
4f51507e4b Update Mockk to v1.14.5 (#5562) 2025-07-23 20:05:22 +00:00
David Perez
88fcd35d1a Update Firebase to v34.0.0 (#5561) 2025-07-23 20:05:04 +00:00
Patrick Honkonen
987639b2a3 [PM-23817] Move PM string to UI module and update Crowdin configuration (#5550) 2025-07-23 19:49:54 +00:00
David Perez
d32b4c7c7e PM-24075: Update Dynamic colors copy (#5560) 2025-07-23 16:20:27 +00:00
David Perez
9ed59e61a3 PM-24035: Add tooltip for website icons (#5554) 2025-07-22 20:06:54 +00:00
David Perez
3342ebf139 PM-19185: Persist pin after a soft-logout (#5555) 2025-07-22 20:06:34 +00:00
Patrick Honkonen
4050215145 Disable MissingTranslation and ExtraTranslation lint checks in UI module (#5558) 2025-07-22 20:03:49 +00:00
Patrick Honkonen
3e0ee5fcd8 [PM-22744] Refactor to use CipherListView as primary cipher source (#5494) 2025-07-22 20:00:08 +00:00
Andy Pixley
fcd7326f2c [BRE-831] Switching to use AKV instead of GitHub secrets (#5553) 2025-07-22 14:53:14 +00:00
David Perez
c94fe56b47 PM-24004: Push notification for sync should bypass 30 minute interval (#5552) 2025-07-21 19:44:13 +00:00
Patrick Honkonen
17287680d9 Allow asterisk in email validation (#5549) 2025-07-21 15:49:16 +00:00
renovate[bot]
e4935318de [deps]: Lock file maintenance (#5548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 14:38:12 +00:00
Amy Galles
f22643fec1 [BRE-768] Automate Google Play publishing (#5256)
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2025-07-21 14:11:30 +00:00
David Perez
6454dc1a58 PM-23878: Move filterTouchesWhenObscured to avoid actionbar issues (#5546) 2025-07-18 22:14:06 +00:00
David Perez
411e359600 PM-23878: Add filter touches when obscured (#5545) 2025-07-18 20:45:12 +00:00
David Perez
e75d7844de PM-23910: Disallow file sends for non-premium users (#5544) 2025-07-18 20:44:52 +00:00
David Perez
25680f9255 PM-18405: Update the AboutScreen copy info (#5538) 2025-07-18 15:19:55 +00:00
David Perez
628cb12081 VULN-261: Filter out send intents that use our own content provider (#5539) 2025-07-18 14:56:01 +00:00
bw-ghapp[bot]
710e35680b Crowdin Pull - Authenticator (#5541)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-18 12:42:38 +00:00
bw-ghapp[bot]
b5cd0c9d9d Crowdin Pull - Password Manager (#5542)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-07-18 12:42:22 +00:00
Carlos Gonçalves
9995fa92f1 [PM-23871] Update Bitwarden SDK (#5537) 2025-07-17 18:39:55 +00:00
André Bispo
44aae70fe4 [PM-23314] Enforce HTTPS (#5533) 2025-07-17 18:27:10 +00:00
Patrick Honkonen
fca4ebe023 [PM-23681] Update TotpCodeManager to use CipherListView (#5532) 2025-07-17 16:10:41 +00:00
Patrick Honkonen
2d2a5e74da Fix unmockkStatic usage in SdkCipherRepositoryTest (#5534) 2025-07-17 00:42:41 +00:00
Michał Chęciński
b53ca30974 [BRE-769] Use Fastlane to keep github releases in sync with mobile deploy versions (#5219)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2025-07-16 15:32:21 +00:00
mpbw2
8178a61dba [PM-22335] Support fastlane dev via rbenv (#5390) 2025-07-16 14:13:21 +00:00
Patrick Honkonen
f0bdc8ede3 Update authenticatorbridge README (#5423) 2025-07-16 13:53:17 +00:00
Andy Pixley
145c19da22 [BRE-831] migrate secrets akv (#5347) 2025-07-15 20:05:10 +00:00
André Bispo
39b1409cbd [PM-22399] Send 2FA email when view appears (#5498)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-07-15 16:31:37 +00:00
André Bispo
f26d54a2e2 [PM-23696] Hide cards from export when policy is enabled. (#5520) 2025-07-15 15:21:39 +00:00
David Perez
33cfaa5e95 PM-23774: Simplify AuthenticatorBridgeRepositoryImpl (#5529) 2025-07-15 14:15:01 +00:00
David Perez
9274e0f349 Update the Androidx Crypto library (#5527) 2025-07-14 21:36:13 +00:00
David Perez
46656d659e PM-23666: Construct unique SDK client for Authenticator Sync feature (#5510) 2025-07-14 20:53:09 +00:00
Patrick Honkonen
811f0f2757 [PM-23608] Add SDK method for generating TOTP for CipherListView (#5519) 2025-07-14 20:02:20 +00:00
David Perez
8f783a43e4 Update OkHttp to v5.1.0 (#5524) 2025-07-14 19:23:37 +00:00
David Perez
b8f74cdefa Update to Junit v5.13.3 (#5523) 2025-07-14 19:23:21 +00:00
David Perez
5e6dcb5b58 Update to AGP v8.11.1 (#5522) 2025-07-14 19:23:08 +00:00
André Bispo
c5a40a89d9 [PM-23546] Update 2FA verification code accept any length (#5500)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-07-14 17:18:18 +00:00
Carlos Gonçalves
929233081c [PM- 22735] Unsafe deserialization parcel data intent (#5419)
Co-authored-by: David Perez <david@livefront.com>
2025-07-14 14:34:26 +00:00
788 changed files with 22048 additions and 23168 deletions

20
.github/actions/log-inputs/action.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: 'Log Inputs to Job Summary'
description: 'Log workflow inputs to the GitHub Actions job summary'
inputs:
inputs:
description: 'Workflow inputs as JSON'
required: true
runs:
using: 'composite'
steps:
- name: Log inputs to job summary
shell: bash
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ inputs.inputs }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- release/**/*
workflow_dispatch:
inputs:
version-name:
@@ -32,6 +33,7 @@ env:
permissions:
contents: read
packages: read
id-token: write
jobs:
build:
@@ -52,7 +54,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -80,7 +82,7 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -111,7 +113,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -122,9 +124,18 @@ jobs:
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "BWA-AAB-KEYSTORE-STORE-PASSWORD,BWA-AAB-KEYSTORE-KEY-PASSWORD,BWA-APK-KEYSTORE-STORE-PASSWORD,BWA-APK-KEYSTORE-KEY-PASSWORD"
- name: Retrieve secrets
env:
@@ -168,6 +179,9 @@ jobs:
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
- name: AZ Logout
uses: bitwarden/gh-actions/azure-logout@main
- name: Verify Play Store credentials
if: ${{ inputs.publish-to-play-store }}
run: |
@@ -175,7 +189,7 @@ jobs:
json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -206,12 +220,12 @@ jobs:
run: |
DEFAULT_VERSION_CODE=$GITHUB_RUN_NUMBER
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
bundle exec fastlane setAuthenticatorBuildVersionInfo \
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
regex='versionName = "([^"]+)"'
if [[ "$(cat authenticator/build.gradle.kts)" =~ $regex ]]; then
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY
@@ -222,18 +236,18 @@ jobs:
run: |
bundle exec fastlane bundleAuthenticatorRelease \
storeFile:${{ github.workspace }}/keystores/authenticator_aab-keystore.jks \
storePassword:'${{ secrets.BWA_AAB_KEYSTORE_STORE_PASSWORD }}' \
storePassword:'${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-STORE-PASSWORD }}' \
keyAlias:authenticatorupload \
keyPassword:'${{ secrets.BWA_AAB_KEYSTORE_KEY_PASSWORD }}'
keyPassword:'${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-KEY-PASSWORD }}'
- name: Generate release Play Store APK
if: ${{ matrix.variant == 'apk' }}
run: |
bundle exec fastlane buildAuthenticatorRelease \
storeFile:${{ github.workspace }}/keystores/authenticator_apk-keystore.jks \
storePassword:'${{ secrets.BWA_APK_KEYSTORE_STORE_PASSWORD }}' \
storePassword:'${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-STORE-PASSWORD }}' \
keyAlias:bitwardenauthenticator \
keyPassword:'${{ secrets.BWA_APK_KEYSTORE_KEY_PASSWORD }}'
keyPassword:'${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-KEY-PASSWORD }}'
- name: Upload release Play Store .aab artifact
if: ${{ matrix.variant == 'aab' }}

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- release/**/*
workflow_dispatch:
inputs:
version-name:
@@ -33,6 +34,7 @@ env:
permissions:
contents: read
packages: read
id-token: write
jobs:
build:
@@ -53,7 +55,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -81,7 +83,7 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -119,7 +121,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -130,9 +132,18 @@ jobs:
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
- name: Retrieve secrets
env:
@@ -169,8 +180,11 @@ jobs:
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -216,48 +230,48 @@ jobs:
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
env:
UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
UPLOAD-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreRelease \
storeFile:app_upload-keystore.jks \
storePassword:${{ env.UPLOAD_KEYSTORE_PASSWORD }} \
storePassword:${{ env.UPLOAD-KEYSTORE-PASSWORD }} \
keyAlias:upload \
keyPassword:${{ env.UPLOAD_KEYSTORE_PASSWORD }}
keyPassword:${{ env.UPLOAD-KEYSTORE-PASSWORD }}
- name: Generate beta Play Store bundle
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
env:
UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_BETA_KEYSTORE_PASSWORD }}
UPLOAD_BETA_KEY_PASSWORD: ${{ secrets.UPLOAD_BETA_KEY_PASSWORD }}
UPLOAD-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
UPLOAD-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreBeta \
storeFile:app_beta_upload-keystore.jks \
storePassword:${{ env.UPLOAD_BETA_KEYSTORE_PASSWORD }} \
storePassword:${{ env.UPLOAD-BETA-KEYSTORE-PASSWORD }} \
keyAlias:bitwarden-beta-upload \
keyPassword:${{ env.UPLOAD_BETA_KEY_PASSWORD }}
keyPassword:${{ env.UPLOAD-BETA-KEY-PASSWORD }}
- name: Generate release Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY_KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD }}
PLAY-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreReleaseApk \
storeFile:app_play-keystore.jks \
storePassword:${{ env.PLAY_KEYSTORE_PASSWORD }} \
storePassword:${{ env.PLAY-KEYSTORE-PASSWORD }} \
keyAlias:bitwarden \
keyPassword:${{ env.PLAY_KEYSTORE_PASSWORD }}
keyPassword:${{ env.PLAY-KEYSTORE-PASSWORD }}
- name: Generate beta Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY_BETA_KEYSTORE_PASSWORD: ${{ secrets.PLAY_BETA_KEYSTORE_PASSWORD }}
PLAY_BETA_KEY_PASSWORD: ${{ secrets.PLAY_BETA_KEY_PASSWORD }}
PLAY-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreBetaApk \
storeFile:app_beta_play-keystore.jks \
storePassword:${{ env.PLAY_BETA_KEYSTORE_PASSWORD }} \
storePassword:${{ env.PLAY-BETA-KEYSTORE-PASSWORD }} \
keyAlias:bitwarden-beta \
keyPassword:${{ env.PLAY_BETA_KEY_PASSWORD }}
keyPassword:${{ env.PLAY-BETA-KEY-PASSWORD }}
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
@@ -418,7 +432,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -429,9 +443,18 @@ jobs:
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "FDROID-KEYSTORE-PASSWORD,FDROID-BETA-KEYSTORE-PASSWORD,FDROID-BETA-KEY-PASSWORD"
- name: Retrieve secrets
env:
@@ -454,8 +477,11 @@ jobs:
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -500,15 +526,15 @@ jobs:
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
regex='versionName = "([^"]+)"'
if [[ "$(cat app/build.gradle.kts)" =~ $regex ]]; then
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY
echo "Version Number: $VERSION_CODE" >> $GITHUB_STEP_SUMMARY
- name: Generate F-Droid artifacts
env:
FDROID_STORE_PASSWORD: ${{ secrets.FDROID_KEYSTORE_PASSWORD }}
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane assembleFDroidReleaseApk \
storeFile:app_fdroid-keystore.jks \
@@ -518,14 +544,14 @@ jobs:
- name: Generate F-Droid Beta Artifacts
env:
FDROID_BETA_KEYSTORE_PASSWORD: ${{ secrets.FDROID_BETA_KEYSTORE_PASSWORD }}
FDROID_BETA_KEY_PASSWORD: ${{ secrets.FDROID_BETA_KEY_PASSWORD }}
FDROID-BETA-KEYSTORE-PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEYSTORE-PASSWORD }}
FDROID-BETA-KEY-PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane assembleFDroidBetaApk \
storeFile:app_beta_fdroid-keystore.jks \
storePassword:"${{ env.FDROID_BETA_KEYSTORE_PASSWORD }}" \
storePassword:"${{ env.FDROID-BETA-KEYSTORE-PASSWORD }}" \
keyAlias:bitwarden-beta \
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
keyPassword:"${{ env.FDROID-BETA-KEY-PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -8,30 +8,29 @@ on:
jobs:
crowdin-sync:
name: Crowdin Pull - ${{ matrix.name }} - ${{ github.event_name }}
name: Crowdin Pull - ${{ github.event_name }}
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
strategy:
matrix:
include:
- name: Password Manager
project_id: 269690
config: crowdin-bwpm.yml
branch: crowdin-pull-bwpm
- name: Authenticator
project_id: 673718
config: crowdin-bwa.yml
branch: crowdin-pull-bwa
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to Azure - CI Subscription
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Retrieve secrets
id: retrieve-secrets
@@ -40,30 +39,33 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Download translations for ${{ matrix.name }}
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
- name: Download translations
uses: crowdin/github-action@9fd07c1c5b36b15f082d1d860dc399f16f849bd7 # v2.9.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: ${{ matrix.project_id }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: ${{ matrix.config }}
config: crowdin.yml
upload_sources: false
upload_translations: false
download_translations: true
github_user_name: "bitwarden-devops-bot"
github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
commit_message: "Crowdin Pull - ${{ matrix.name }}"
localization_branch_name: ${{ matrix.branch }}
commit_message: "Crowdin Pull"
localization_branch_name: "crowdin-pull"
create_pull_request: true
pull_request_title: "Crowdin Pull - ${{ matrix.name }}"
pull_request_body: ":inbox_tray: New translations for ${{ matrix.name }} received!"
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -13,14 +13,17 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to Azure
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -29,24 +32,16 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Upload sources for Password Manager
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
- name: Upload sources
uses: crowdin/github-action@9fd07c1c5b36b15f082d1d860dc399f16f849bd7 # v2.9.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: crowdin-bwpm.yml
config: crowdin.yml
upload_sources: true
upload_translations: false
- name: Upload sources for Authenticator
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "673718"
with:
config: crowdin-bwa.yml
upload_sources: true
upload_translations: false
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@@ -21,6 +21,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
steps:
- name: Check out repository
@@ -28,6 +29,11 @@ jobs:
with:
fetch-depth: 0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
with:
inputs: ${{ toJson(inputs) }}
- name: Get branch from workflow run
id: get_release_branch
env:
@@ -44,23 +50,29 @@ jobs:
exit 1
fi
echo "🔖 Release branch: $release_branch"
echo "🔖 Workflow name: $workflow_name"
echo "release_branch=$release_branch" >> $GITHUB_OUTPUT
echo "workflow_name=$workflow_name" >> $GITHUB_OUTPUT
case "$workflow_name" in
*"Password Manager"* | "Build")
echo "app_name=Password Manager" >> $GITHUB_OUTPUT
echo "app_name_suffix=bwpm" >> $GITHUB_OUTPUT
app_name="Password Manager"
app_name_suffix="bwpm"
;;
*"Authenticator"*)
echo "app_name=Authenticator" >> $GITHUB_OUTPUT
echo "app_name_suffix=bwa" >> $GITHUB_OUTPUT
app_name="Authenticator"
app_name_suffix="bwa"
;;
*)
echo "::error::Unknown workflow name: $workflow_name"
exit 1
;;
esac
echo "🔖 App name: $app_name"
echo "🔖 App name suffix: $app_name_suffix"
echo "app_name=$app_name" >> $GITHUB_OUTPUT
echo "app_name_suffix=$app_name_suffix" >> $GITHUB_OUTPUT
- name: Get version info from run logs and set release tag name
id: get_release_info
@@ -98,7 +110,7 @@ jobs:
echo "🔖 New tag name: $tag_name"
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
last_release_tag=$(git tag -l --sort=-authordate | grep "$APP_NAME_SUFFIX" | head -n 1)
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
echo "🔖 Last release tag: $last_release_tag"
echo "last_release_tag=$last_release_tag" >> $GITHUB_OUTPUT
@@ -115,6 +127,51 @@ jobs:
find $ARTIFACTS_PATH -type f
fi
# Files that won't be included in any release
files_to_remove=(
"com.x8bit.bitwarden.aab"
"com.x8bit.bitwarden.aab-sha256.txt"
"com.x8bit.bitwarden.beta.apk"
"com.x8bit.bitwarden.beta.apk-sha256.txt"
"com.x8bit.bitwarden.beta.aab"
"com.x8bit.bitwarden.beta.aab-sha256.txt"
"com.x8bit.bitwarden.beta-fdroid.apk"
"com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt"
"com.x8bit.bitwarden.dev.apk"
"com.x8bit.bitwarden.dev.apk-sha256.txt"
"com.bitwarden.authenticator.aab"
"authenticator-android-aab-sha256.txt"
)
for file in "${files_to_remove[@]}"; do
find $ARTIFACTS_PATH -name "$file" -type f -delete
done
echo "🔖 Removed internal artifacts."
echo ""
echo "🔖 Files to be included in the release:"
find $ARTIFACTS_PATH -type f
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Get product release notes
id: get_release_notes
env:
@@ -122,8 +179,8 @@ jobs:
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
_JIRA_API_EMAIL: ${{ secrets.JIRA_API_EMAIL }}
_JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
run: |
echo "Getting product release notes"
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py $_RELEASE_TICKET_ID $_JIRA_API_EMAIL $_JIRA_API_TOKEN)
@@ -149,34 +206,39 @@ jobs:
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
run: |
is_latest_release=false
if [[ "$_APP_NAME" == "Password Manager" ]]; then
is_latest_release=true
fi
echo "⌛️ Creating release for $_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER) on $_TARGET_COMMIT"
release_url=$(gh release create "$_TAG_NAME" \
--title "$_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER)" \
--target "$_TARGET_COMMIT" \
--generate-notes \
--notes-start-tag "$_LAST_RELEASE_TAG" \
--latest=$is_latest_release \
--draft \
$ARTIFACTS_PATH/*/*)
echo "✅ Release created: $release_url"
# Get release info for outputs
release_data=$(gh release view "$_TAG_NAME" --json id)
release_id=$(echo "$release_data" | jq -r .id)
echo "id=$release_id" >> $GITHUB_OUTPUT
# Extract release tag from URL
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
echo "release_id_from_url=$release_id_from_url" >> $GITHUB_OUTPUT
echo "url=$release_url" >> $GITHUB_OUTPUT
echo "✅ Release created: $release_url"
echo "🔖 Release ID from URL: $release_id_from_url"
- name: Update Release Description
id: update_release_description
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
_RELEASE_ID: ${{ steps.create_release.outputs.release_id_from_url }}
run: |
echo "Getting current release body. Tag: $_TAG_NAME"
current_body=$(gh release view "$_TAG_NAME" --json body --jq .body)
echo "Getting current release body. Release ID: $_RELEASE_ID"
current_body=$(gh release view "$_RELEASE_ID" --json body --jq .body)
product_release_notes=$(cat product_release_notes.txt)
@@ -187,7 +249,7 @@ jobs:
${current_body}
**Builds Source:** https://github.com/${{ github.repository }}/actions/runs/$ARTIFACT_RUN_ID"
new_release_url=$(gh release edit "$_TAG_NAME" --notes "$updated_body")
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
# draft release links change after editing
echo "release_url=$new_release_url" >> $GITHUB_OUTPUT

View File

@@ -1,14 +1,36 @@
name: Publish GitHub Release as newest
name: Publish Password Manager and Authenticator GitHub Release as newest
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * 1-5'
permissions: {}
permissions:
contents: write
id-token: write
actions: read
jobs:
stub:
runs-on: ubuntu-24.04
name: Stub
steps:
- name: Stub
run: echo "This is a stub job to trigger the workflow."
publish-release-password-manager:
name: Publish Password Manager Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Password Manager"
workflow_name: "publish-github-release.yml"
credentials_filename: "play_creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit
publish-release-authenticator:
name: Publish Authenticator Release
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
with:
release_name: "Authenticator"
workflow_name: "publish-github-release.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@@ -1,16 +1,159 @@
name: Publish
name: Publish to Google Play
run-name: "Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}"
on:
workflow_dispatch:
inputs:
product:
description: "Which app is being released."
type: choice
options:
- Password Manager
- Authenticator
version-name:
description: "Version name to promote to production ex 2025.1.1"
type: string
version-code:
description: "Build number to promote to production."
required: true
type: string
rollout-percentage:
description: "Percentage of users who will receive this version update."
required: true
type: choice
options:
- 10%
- 30%
- 50%
- 100%
default: 10%
release-notes:
description: "Change notes to be included with this release."
type: string
default: "Bug fixes."
required: true
track-from:
description: "Track to promote from."
type: choice
options:
- internal
- Fastlane Automation Source
required: true
default: "internal"
track-target:
description: "Track to promote to."
type: choice
options:
- production
- Fastlane Automation Target
required: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
permissions: {}
permissions:
contents: read
packages: read
id-token: write
jobs:
publish:
promote:
runs-on: ubuntu-24.04
name: Promote build to Production in Play Store
steps:
- name: TEST STEP
run: exit 0
- name: Log inputs to job summary
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
- name: Retrieve secrets
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
mkdir -p ${{ github.workspace }}/app/src/standardRelease
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Format Release Notes
run: |
FORMATTED_MESSAGE="$(echo "${{ inputs.release-notes }}" | sed 's/ /\n/g')"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$FORMATTED_MESSAGE" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Promote Play Store version to production
env:
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
VERSION_CODE_INPUT: ${{ inputs.version-code }}
VERSION_NAME: ${{inputs.version-name}}
ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }}
PRODUCT: ${{ inputs.product }}
TRACK_FROM: ${{ inputs.track-from }}
TRACK_TARGET: ${{ inputs.track-target }}
run: |
if [ "$PRODUCT" = "Password Manager" ]; then
PACKAGE_NAME="com.x8bit.bitwarden"
elif [ "$PRODUCT" = "Authenticator" ]; then
PACKAGE_NAME="com.bitwarden.authenticator"
else
echo "Unsupported product: $PRODUCT"
exit 1
fi
VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',')
decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc)
bundle exec fastlane updateReleaseNotes \
releaseNotes:"$RELEASE_NOTES" \
versionCode:"$VERSION_CODE"
bundle exec fastlane promoteToProduction \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME" \
rolloutPercentage:"$decimal" \
packageName:"$PACKAGE_NAME" \
releaseNotes:"$RELEASE_NOTES" \
track:"$TRACK_FROM" \
trackPromoteTo:"$TRACK_TARGET"

View File

@@ -10,6 +10,7 @@ on:
options:
- RC
- Hotfix
- Test
jobs:
create-release-branch:
@@ -17,29 +18,36 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
actions: write
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Create RC Branch
if: inputs.release_type == 'RC'
- name: Create RC or Test Branch
id: rc_branch
if: inputs.release_type == 'RC' || inputs.release_type == 'Test'
env:
RC_PREFIX_DATE: "true" # replace with input if needed
_TEST_MODE: ${{ inputs.release_type == 'Test' }}
_RELEASE_TYPE: ${{ inputs.release_type }}
run: |
if [ "$RC_PREFIX_DATE" = "true" ]; then
current_date=$(date +'%Y.%m')
branch_name="release/${current_date}-rc${{ github.run_number }}"
else
branch_name="release/rc${{ github.run_number }}"
current_date=$(date +'%Y.%-m')
branch_name="${current_date}-rc${{ github.run_number }}"
if [ "$_TEST_MODE" = "true" ]; then
branch_name="WORKFLOW-TEST-${branch_name}"
fi
branch_name="release/${branch_name}"
git switch main
git switch -c $branch_name
git push origin $branch_name
echo "# :cherry_blossom: RC branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
echo "# :cherry_blossom: ${_RELEASE_TYPE} branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
- name: Create Hotfix Branch
id: hotfix_branch
if: inputs.release_type == 'Hotfix'
run: |
latest_tag=$(git tag -l --sort=-creatordate | head -n 1)
@@ -49,6 +57,7 @@ jobs:
fi
branch_name="release/hotfix-${latest_tag}"
echo "🌿 branch name: $branch_name"
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
if git show-ref --verify --quiet "refs/remotes/origin/$branch_name"; then
echo "# :fire: :warning: Hotfix branch already exists: ${branch_name}" >> $GITHUB_STEP_SUMMARY
exit 0
@@ -56,3 +65,12 @@ jobs:
git switch -c $branch_name $latest_tag
git push origin $branch_name
echo "# :fire: Hotfix branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
- name: Trigger CI Workflows
env:
GH_TOKEN: ${{ github.token }}
_BRANCH_NAME: ${{ steps.rc_branch.outputs.branch_name || steps.hotfix_branch.outputs.branch_name }}
run: |
echo "🌿 branch name: $_BRANCH_NAME"
gh workflow run build.yml --ref $_BRANCH_NAME -f distribute-to-firebase=true -f publish-to-play-store=true
gh workflow run build-authenticator.yml --ref $_BRANCH_NAME -f distribute-to-firebase=true -f publish-to-play-store=true

View File

@@ -6,55 +6,38 @@ on:
branches:
- "main"
permissions: {}
jobs:
sast:
name: SAST scan
runs-on: ubuntu-24.04
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
sast:
name: Checkmarx
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@ef93013c95adc60160bc22060875e90800d3ecfc # 2.3.19
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path .
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
with:
sarif_file: cx_result.sarif
id-token: write
quality:
name: Quality scan
runs-on: ubuntu-24.04
name: Sonar
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Scan with SonarCloud
uses: sonarsource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
pull-requests: write
id-token: write

View File

@@ -21,63 +21,28 @@ jobs:
contents: read
sast:
name: SAST scan
runs-on: ubuntu-24.04
name: Checkmarx
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@ef93013c95adc60160bc22060875e90800d3ecfc # 2.3.19
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
id-token: write
quality:
name: Quality scan
runs-on: ubuntu-24.04
name: Sonar
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
id-token: write

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Cache Gradle files
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
@@ -52,7 +52,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
uses: ruby/setup-ruby@bb6434c747fa7022e12fa1cae2a0951fcffcff26 # v1.253.0
with:
bundler-cache: true
@@ -91,7 +91,7 @@ jobs:
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:

View File

@@ -1 +1 @@
3.3.1
3.4.2

View File

@@ -7,3 +7,12 @@ gem 'time'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
# Since ruby 3.4.0 these are not included in the standard library
gem 'abbrev'
gem 'logger'
gem 'mutex_m'
gem 'csv'
# Starting with Ruby 3.5.0, these are not included in the standard library
gem 'ostruct'

View File

@@ -5,35 +5,39 @@ GEM
base64
nkf
rexml
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1125.0)
aws-sdk-core (3.226.2)
aws-partitions (1.1139.0)
aws-sdk-core (3.228.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (1.109.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.192.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-s3 (1.195.0)
aws-sdk-core (~> 3, >= 3.228.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)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
date (3.4.1)
declarative (0.0.20)
digest-crc (0.7.0)
@@ -165,13 +169,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.12.2)
json (2.13.2)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.17.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -179,6 +183,7 @@ GEM
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
@@ -230,12 +235,17 @@ PLATFORMS
ruby
DEPENDENCIES
abbrev
csv
fastlane
fastlane-plugin-firebase_app_distribution
logger
mutex_m
ostruct
time
RUBY VERSION
ruby 3.3.1p55
ruby 3.4.2p28
BUNDLED WITH
2.6.6
2.6.9

View File

@@ -55,17 +55,24 @@ android {
applicationId = "com.x8bit.bitwarden"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "2025.4.0"
setProperty("archivesBaseName", "com.x8bit.bitwarden")
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Set the base archive name for publishing purposes. This is used to derive the APK and AAB
// artifact names when uploading to Firebase and Play Store.
base.archivesName = "com.x8bit.bitwarden"
buildConfigField(
type = "String",
name = "CI_INFO",
value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}",
value = "${ciProperties.getOrDefault("ci.info", "\"\uD83D\uDCBB local\"")}",
)
buildConfigField(
type = "String",
name = "SDK_VERSION",
value = "\"${libs.versions.bitwardenSdk.get()}\"",
)
}
@@ -257,11 +264,10 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(platform(libs.square.okhttp.bom))
implementation(libs.square.okhttp)
implementation(libs.square.okhttp.logging)
implementation(platform(libs.square.retrofit.bom))
implementation(libs.square.retrofit)
implementation(libs.square.retrofit.kotlinx.serialization)
implementation(libs.timber)
implementation(libs.zxing.zxing.core)
@@ -289,7 +295,6 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.mockk)
testImplementation(libs.robolectric.robolectric)
testImplementation(libs.square.okhttp.mockwebserver)
testImplementation(libs.square.turbine)
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors>
<!-- Trust pre-installed CAs -->
<certificates src="system" />
<!-- Additionally trust user added CAs -->
<certificates
src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">bitwarden.com</domain>
<domain includeSubdomains="true">bitwarden.eu</domain>
<domain includeSubdomains="true">bitwarden.pw</domain>
<trust-anchors>
<!-- Only trust pre-installed CAs for Bitwarden domains and all subdomains -->
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider>
<capabilities>
<capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" />
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" />
</capabilities>
</credential-provider>

View File

@@ -86,6 +86,7 @@
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />

View File

@@ -1,8 +1,11 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.validate
/**
* An activity to be launched and then immediately closed so that the OS Shade can be collapsed
@@ -11,7 +14,16 @@ import com.bitwarden.annotation.OmitFromCoverage
@OmitFromCoverage
class AccessibilityActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
super.onCreate(savedInstanceState)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent.validate())
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent.validate(), caller)
}
}

View File

@@ -1,10 +1,12 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.validate
import dagger.hilt.android.AndroidEntryPoint
/**
@@ -21,6 +23,7 @@ class AuthCallbackActivity : AppCompatActivity() {
private val viewModel: AuthCallbackViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
super.onCreate(savedInstanceState)
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = intent))
@@ -35,4 +38,12 @@ class AuthCallbackActivity : AppCompatActivity() {
startActivity(intent)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent.validate())
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent.validate(), caller)
}
}

View File

@@ -1,10 +1,13 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
@@ -26,6 +29,7 @@ class AutofillTotpCopyActivity : AppCompatActivity() {
private val autofillTotpCopyViewModel: AutofillTotpCopyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
super.onCreate(savedInstanceState)
observeViewModelEvents()
@@ -37,6 +41,14 @@ class AutofillTotpCopyActivity : AppCompatActivity() {
)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent.validate())
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent.validate(), caller)
}
private fun observeViewModelEvents() {
autofillTotpCopyViewModel
.eventFlow

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -14,6 +15,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -23,6 +25,7 @@ import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.setupEdgeToEdge
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
@@ -41,6 +44,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val ANDROID_15_BUG_MAX_REVISION: Int = 241007
/**
* Primary entry point for the application.
*/
@@ -67,10 +72,11 @@ class MainActivity : AppCompatActivity() {
lateinit var debugLaunchManager: DebugMenuLaunchManager
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
window.decorView.filterTouchesWhenObscured = true
if (savedInstanceState == null) {
mainViewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = intent))
}
@@ -114,8 +120,15 @@ class MainActivity : AppCompatActivity() {
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
mainViewModel.trySendAction(action = MainAction.ReceiveNewIntent(intent = intent))
val newIntent = intent.validate()
super.onNewIntent(newIntent)
mainViewModel.trySendAction(action = MainAction.ReceiveNewIntent(intent = newIntent))
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
val newIntent = intent.validate()
super.onNewIntent(newIntent, caller)
mainViewModel.trySendAction(action = MainAction.ReceiveNewIntent(intent = newIntent))
}
override fun onResume() {
@@ -211,7 +224,35 @@ class MainActivity : AppCompatActivity() {
}
private fun handleRecreate() {
recreate()
val isOldAndroidBuildRevision = {
// This fetches the date portion of the ID in order to determine the revision of
// Android 15 being used and whether we want to use the `recreate` API or not.
// If we fail to parse a date, we assume it is not an old revision.
"\\.([^.]+)\\."
.toRegex()
.find(Build.ID)
?.groups
?.get(1)
?.value
?.toIntOrNull()
?.let { it <= ANDROID_15_BUG_MAX_REVISION } == true
}
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM &&
isOldAndroidBuildRevision()
) {
// This is done to avoid a bug in specific older revisions of Android 15. The bug has
// been fixed but certain phones that are no longer supported will never get the fix.
// The OS bug is tracked here: https://issuetracker.google.com/issues/370180732
startActivity(
Intent
.makeMainActivity(componentName)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION),
)
finish()
overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
} else {
ActivityCompat.recreate(this)
}
}
private fun updateScreenCapture(isScreenCaptureAllowed: Boolean) {

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherView
@@ -22,13 +23,12 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -44,12 +44,15 @@ import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -58,17 +61,17 @@ import java.time.Clock
import javax.inject.Inject
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
private const val ANIMATION_REFRESH_DELAY = 500L
private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
/**
* A view model that helps launch actions for the [MainActivity].
*/
@OptIn(FlowPreview::class)
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
featureFlagManager: FeatureFlagManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -85,9 +88,6 @@ class MainViewModel @Inject constructor(
initialState = MainState(
theme = settingsRepository.appTheme,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isErrorReportingDialogEnabled = featureFlagManager.getFeatureFlag(
key = FlagKey.MobileErrorReporting,
),
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
),
) {
@@ -106,12 +106,6 @@ class MainViewModel @Inject constructor(
.onEach { specialCircumstance = it }
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(key = FlagKey.MobileErrorReporting)
.map { MainAction.Internal.OnMobileErrorReportingReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
accessibilitySelectionManager
.accessibilitySelectionFlow
.map { MainAction.Internal.AccessibilitySelectionReceive(it) }
@@ -145,36 +139,23 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
authRepository
.userStateFlow
.drop(count = 1)
// Trigger an action whenever the current user changes or we go into/out of a pending
// account state (which acts like switching to a temporary user).
.map { it?.activeUserId to it?.hasPendingAccountAddition }
.distinctUntilChanged()
.onEach {
// Switching between account states often involves some kind of animation (ex:
// account switcher) that we might want to give time to finish before triggering
// a refresh.
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.CurrentUserStateChange)
}
.launchIn(viewModelScope)
vaultRepository
.vaultStateEventFlow
.onEach {
when (it) {
is VaultStateEvent.Locked -> {
// Similar to account switching, triggering this action too soon can
// interfere with animations or navigation logic, so we will delay slightly.
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.VaultUnlockStateChange)
}
is VaultStateEvent.Unlocked -> Unit
}
}
merge(
authRepository
.userStateFlow
.drop(count = 1)
// Trigger an action whenever the current user changes or we go into/out of a
// pending account state (which acts like switching to a temporary user).
.map { it?.activeUserId to it?.hasPendingAccountAddition }
.distinctUntilChanged(),
vaultRepository
.vaultStateEventFlow
.filter { it is VaultStateEvent.Locked },
)
// This debounce ensure we do not emit multiple times rapidly and also acts as a short
// delay to give animations time to finish (ex: account switcher).
.debounce(timeoutMillis = ANIMATION_DEBOUNCE_DELAY_MS)
.map { MainAction.Internal.CurrentUserOrVaultStateChange }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
@@ -212,22 +193,13 @@ class MainViewModel @Inject constructor(
handleAutofillSelectionReceive(action)
}
is MainAction.Internal.CurrentUserStateChange -> handleCurrentUserStateChange()
is MainAction.Internal.CurrentUserOrVaultStateChange -> {
handleCurrentUserOrVaultStateChange()
}
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.OnMobileErrorReportingReceive -> {
handleOnMobileErrorReportingReceive(action)
}
}
}
private fun handleOnMobileErrorReportingReceive(
action: MainAction.Internal.OnMobileErrorReportingReceive,
) {
mutableStateFlow.update {
it.copy(isErrorReportingDialogEnabled = action.isErrorReportingEnabled)
}
}
@@ -260,8 +232,9 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.CompleteAutofill(cipherView = action.cipherView))
}
private fun handleCurrentUserStateChange() {
recreateUiAndGarbageCollect()
private fun handleCurrentUserOrVaultStateChange() {
sendEvent(MainEvent.Recreate)
garbageCollectionManager.tryCollect()
}
private fun handleScreenCaptureUpdate(action: MainAction.Internal.ScreenCaptureUpdate) {
@@ -273,10 +246,6 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.UpdateAppTheme(osTheme = action.theme.osValue))
}
private fun handleVaultUnlockStateChange() {
recreateUiAndGarbageCollect()
}
private fun handleDynamicColorsUpdate(action: MainAction.Internal.DynamicColorsUpdate) {
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
@@ -325,6 +294,7 @@ class MainViewModel @Inject constructor(
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@@ -415,6 +385,19 @@ class MainViewModel @Inject constructor(
)
}
providerGetPasswordRequest != null -> {
// Set the user's verification status when a new GetPassword request is
// received to force explicit verification if the user's vault is
// unlocked when the request is received.
bitwardenCredentialManager.isUserVerified =
providerGetPasswordRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = providerGetPasswordRequest,
)
}
getCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetCredentials(
@@ -438,11 +421,6 @@ class MainViewModel @Inject constructor(
}
}
private fun recreateUiAndGarbageCollect() {
sendEvent(MainEvent.Recreate)
garbageCollectionManager.tryCollect()
}
private fun handleCompleteRegistrationData(data: CompleteRegistrationData) {
viewModelScope.launch {
// Attempt to load the environment for the user if they have a pre-auth environment
@@ -460,7 +438,8 @@ class MainViewModel @Inject constructor(
message = emailTokenResult
.message
?.asText()
?: R.string.there_was_an_issue_validating_the_registration_token
?: BitwardenString
.there_was_an_issue_validating_the_registration_token
.asText(),
),
)
@@ -495,15 +474,12 @@ data class MainState(
val theme: AppTheme,
val isScreenCaptureAllowed: Boolean,
val isDynamicColorsEnabled: Boolean,
private val isErrorReportingDialogEnabled: Boolean,
) : Parcelable {
/**
* Contains all feature flags that are available to the UI.
*/
val featureFlagsState: FeatureFlagsState
get() = FeatureFlagsState(
isErrorReportingDialogEnabled = isErrorReportingDialogEnabled,
)
get() = FeatureFlagsState
}
/**
@@ -548,13 +524,6 @@ sealed class MainAction {
val cipherView: CipherView,
) : Internal()
/**
* Indicates the Mobile Error Reporting feature flag has been updated.
*/
data class OnMobileErrorReportingReceive(
val isErrorReportingEnabled: Boolean,
) : Internal()
/**
* Indicates the user has manually selected the given [cipherView] for autofill.
*/
@@ -563,9 +532,9 @@ sealed class MainAction {
) : Internal()
/**
* Indicates a relevant change in the current user state.
* Indicates a relevant change in the current user state or vault locked state.
*/
data object CurrentUserStateChange : Internal()
data object CurrentUserOrVaultStateChange : Internal()
/**
* Indicates that the screen capture state has changed.
@@ -581,11 +550,6 @@ sealed class MainAction {
val theme: AppTheme,
) : Internal()
/**
* Indicates a relevant change in the current vault lock state.
*/
data object VaultUnlockStateChange : Internal()
/**
* Indicates that the dynamic colors state has changed.
*/

View File

@@ -2,12 +2,14 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Container for the user's API tokens.
*
* @property accessToken The user's primary access token.
* @property refreshToken The user's refresh token.
* @property expiresAtSec The time at which the token expires in epoch seconds.
*/
@Serializable
data class AccountTokensJson(
@@ -16,6 +18,9 @@ data class AccountTokensJson(
@SerialName("refreshToken")
val refreshToken: String?,
@SerialName("expiresAtSec")
val expiresAtSec: Long = Instant.MAX.epochSecond,
) {
/**
* Returns `true` if the user is logged in, `false otherwise.

View File

@@ -10,7 +10,7 @@ import androidx.core.app.NotificationManagerCompat
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.util.createPasswordlessRequestDataIntent
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
@@ -49,14 +49,14 @@ class AuthRequestNotificationManagerImpl(
NOTIFICATION_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_DEFAULT,
)
.setName(context.getString(R.string.pending_log_in_requests))
.setName(context.getString(BitwardenString.pending_log_in_requests))
.build(),
)
if (!notificationManager.areNotificationsEnabled(NOTIFICATION_CHANNEL_ID)) return
// Create the notification
val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setContentIntent(createContentIntent(data))
.setContentTitle(context.getString(R.string.log_in_requested))
.setContentTitle(context.getString(BitwardenString.log_in_requested))
.setContentText(
authDiskSource
.userState
@@ -64,8 +64,8 @@ class AuthRequestNotificationManagerImpl(
?.get(data.userId)
?.profile
?.email
?.let { context.getString(R.string.confim_log_in_attemp_for_x, it) }
?: context.getString(R.string.confirm_log_in),
?.let { context.getString(BitwardenString.confim_log_in_attemp_for_x, it) }
?: context.getString(BitwardenString.confirm_log_in),
)
.setSmallIcon(BitwardenDrawable.ic_notification)
.setColor(Color.White.value.toInt())

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.network.model.AuthTokenData
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
/**
@@ -9,9 +10,19 @@ class AuthTokenManagerImpl(
private val authDiskSource: AuthDiskSource,
) : AuthTokenManager {
override fun getActiveAccessTokenOrNull(): String? = authDiskSource
override fun getAuthTokenDataOrNull(): AuthTokenData? = authDiskSource
.userState
?.activeUserId
?.let { authDiskSource.getAccountTokens(it) }
?.accessToken
?.let { userId ->
authDiskSource
.getAccountTokens(userId = userId)
?.takeIf { it.accessToken != null }
?.let {
AuthTokenData(
userId = userId,
accessToken = requireNotNull(it.accessToken),
expiresAtSec = it.expiresAtSec,
)
}
}
}

View File

@@ -4,7 +4,7 @@ import androidx.annotation.StringRes
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.R
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
@@ -48,7 +48,7 @@ class UserLogoutManagerImpl(
Timber.d("logout reason=$reason")
val isExpired = reason == LogoutReason.SecurityStamp
if (isExpired) {
showToast(message = R.string.login_expired)
showToast(message = BitwardenString.login_expired)
}
val ableToSwitchToNewAccount = switchUserIfAvailable(
@@ -70,7 +70,7 @@ class UserLogoutManagerImpl(
Timber.d("softLogout reason=$reason")
val isExpired = reason == LogoutReason.SecurityStamp
if (isExpired) {
showToast(message = R.string.login_expired)
showToast(message = BitwardenString.login_expired)
}
authDiskSource.storeAccountTokens(
userId = userId,
@@ -80,6 +80,7 @@ class UserLogoutManagerImpl(
// Save any data that will still need to be retained after otherwise clearing all dat
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId)
switchUserIfAvailable(
currentUserId = userId,
@@ -101,6 +102,10 @@ class UserLogoutManagerImpl(
vaultTimeoutAction = vaultTimeoutAction,
)
}
authDiskSource.storePinProtectedUserKey(
userId = userId,
pinProtectedUserKey = pinProtectedUserKey,
)
}
private fun clearData(userId: String) {
@@ -135,7 +140,7 @@ class UserLogoutManagerImpl(
// Check if there is a new active user
return if (updatedAccounts.isNotEmpty()) {
if (currentUserId == currentUserState.activeUserId && !isExpired) {
showToast(message = R.string.account_switched_automatically)
showToast(message = BitwardenString.account_switched_automatically)
}
// If we logged out a non-active user, we want to leave the active user unchanged.

View File

@@ -146,6 +146,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.Clock
import javax.inject.Singleton
/**
@@ -154,6 +155,7 @@ import javax.inject.Singleton
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton
class AuthRepositoryImpl(
private val clock: Clock,
private val accountsService: AccountsService,
private val devicesService: DevicesService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
@@ -404,10 +406,7 @@ class AuthRepositoryImpl(
.onEach {
val userId = activeUserId ?: return@onEach
// TODO: [PM-20593] Investigate why tokens are explicitly refreshed.
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = false,
)
refreshAccessTokenSynchronously(userId = userId)
vaultRepository.sync(forced = true)
}
// This requires the ioScope to ensure that refreshAccessTokenSynchronously
@@ -760,11 +759,59 @@ class AuthRepositoryImpl(
orgIdentifier = organizationIdentifier,
)
override fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson> =
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = true,
)
override fun refreshAccessTokenSynchronously(
userId: String,
): Result<String> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.flatMap { refreshTokenResponse ->
when (refreshTokenResponse) {
is RefreshTokenResponseJson.Error -> {
if (refreshTokenResponse.isInvalidGrant) {
logout(userId = userId, reason = LogoutReason.InvalidGrant)
}
IllegalStateException(refreshTokenResponse.error).asFailure()
}
is RefreshTokenResponseJson.Forbidden -> {
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
refreshTokenResponse.error.asFailure()
}
is RefreshTokenResponseJson.Unauthorized -> {
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
refreshTokenResponse.error.asFailure()
}
is RefreshTokenResponseJson.Success -> {
// Store the new token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond +
refreshTokenResponse.expiresIn,
),
)
refreshTokenResponse.accessToken.asSuccess()
}
}
}
}
override fun logout(reason: LogoutReason) {
activeUserId?.let { userId -> logout(userId = userId, reason = reason) }
@@ -1422,42 +1469,6 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
private fun refreshAccessTokenSynchronouslyInternal(
userId: String,
logOutOnFailure: Boolean,
): Result<RefreshTokenResponseJson> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.onFailure {
if (logOutOnFailure) {
logout(userId = userId, reason = LogoutReason.TokenRefreshFail)
}
}
.onSuccess { refreshTokenResponse ->
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
),
)
}
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
@@ -1780,6 +1791,7 @@ class AuthRepositoryImpl(
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true

View File

@@ -27,6 +27,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@@ -39,6 +40,7 @@ object AuthRepositoryModule {
@Provides
@Singleton
fun providesAuthRepository(
clock: Clock,
accountsService: AccountsService,
devicesService: DevicesService,
identityService: IdentityService,
@@ -61,6 +63,7 @@ object AuthRepositoryModule {
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
devicesService = devicesService,
identityService = identityService,

View File

@@ -29,6 +29,24 @@ sealed class LogoutReason {
data object NoLongerSupported : Biometrics()
}
/**
* Indicates that the logout is happening because the there was an "invalid_grant" response
* from the network.
*/
data object InvalidGrant : LogoutReason()
/**
* Indicates that the logout is happening because the there was a "Forbidden" response from
* token refresh API.
*/
data object RefreshForbidden : LogoutReason()
/**
* Indicates that the logout is happening because the there was a "Unauthorized" response from
* token refresh API.
*/
data object RefreshUnauthorized : LogoutReason()
/**
* Indicates that the logout is happening because of an invalid state.
*/
@@ -58,11 +76,6 @@ sealed class LogoutReason {
*/
data object Timeout : LogoutReason()
/**
* Indicates that the logout is happening because the access token could not be refreshed.
*/
data object TokenRefreshFail : LogoutReason()
/**
* Indicates that the logout is happening because the user tried to unlock the vault
* unsuccessfully too many times.

View File

@@ -2,9 +2,9 @@ package com.x8bit.bitwarden.data.auth.util
import android.content.Context
import android.content.Intent
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.util.getSafeParcelableExtra
private const val NOTIFICATION_DATA: String = "notificationData"

View File

@@ -6,7 +6,7 @@ import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Toast
import com.bitwarden.core.data.manager.toast.ToastManager
import com.x8bit.bitwarden.R
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
@@ -113,7 +113,7 @@ class BitwardenAccessibilityProcessorImpl(
}
?: run {
toastManager.show(
messageId = R.string.autofill_tile_uri_not_found,
messageId = BitwardenString.autofill_tile_uri_not_found,
duration = Toast.LENGTH_LONG,
)
}

View File

@@ -116,15 +116,13 @@ class FilledDataBuilderImpl(
): FilledPartition {
val filledItems = autofillViews
.mapNotNull { autofillView ->
val value = when (autofillView) {
is AutofillView.Card.ExpirationMonth -> autofillCipher.expirationMonth
is AutofillView.Card.ExpirationYear -> autofillCipher.expirationYear
is AutofillView.Card.Number -> autofillCipher.number
is AutofillView.Card.SecurityCode -> autofillCipher.code
}
autofillView.buildFilledItemOrNull(
value = value,
)
autofillCipher
.getAutofillValueOrNull(autofillView)
?.let { value ->
autofillView.buildFilledItemOrNull(
value = value,
)
}
}
return FilledPartition(
@@ -162,6 +160,44 @@ class FilledDataBuilderImpl(
}
}
/**
* Get the autofill value for the given [autofillView], or null if no value is available.
*/
private fun AutofillCipher.Card.getAutofillValueOrNull(autofillView: AutofillView.Card): String? =
when (autofillView) {
is AutofillView.Card.CardholderName -> {
cardholderName.takeIf { it.isNotEmpty() }
}
is AutofillView.Card.ExpirationMonth -> {
expirationMonth.takeIf { it.isNotEmpty() }
}
is AutofillView.Card.ExpirationYear -> {
expirationYear.takeIf { it.isNotEmpty() }
}
is AutofillView.Card.Number -> {
number
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
}
is AutofillView.Card.SecurityCode -> {
code
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
}
is AutofillView.Card.ExpirationDate -> {
if (expirationMonth.isNotBlank() && expirationYear.isNotBlank()) {
expirationMonth.padStart(2, '0') + expirationYear.takeLast(2)
} else {
null
}
}
}
/**
* Get the item at the [index]. If that fails, return the last item in the list. If that also fails,
* return null.

View File

@@ -24,7 +24,6 @@ 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.manager.FeatureFlagManager
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
@@ -59,12 +58,8 @@ object AutofillModule {
@Singleton
@Provides
fun providesBrowserAutofillEnabledManager(
featureFlagManager: FeatureFlagManager,
): BrowserThirdPartyAutofillEnabledManager =
BrowserThirdPartyAutofillEnabledManagerImpl(
featureFlagManager = featureFlagManager,
)
fun providesBrowserAutofillEnabledManager(): BrowserThirdPartyAutofillEnabledManager =
BrowserThirdPartyAutofillEnabledManagerImpl()
@Singleton
@Provides

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -24,17 +24,18 @@ class AutofillTotpManagerImpl(
if (settingsRepository.isAutoCopyTotpDisabled) return
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
if (!isPremium && !cipherView.organizationUseTotp) return
val totpCode = cipherView.login?.totp ?: return
cipherView.login?.totp ?: return
val cipherId = cipherView.id ?: return
val totpResult = vaultRepository.generateTotp(
time = clock.instant(),
totpCode = totpCode,
cipherId = cipherId,
)
if (totpResult is GenerateTotpResult.Success) {
clipboardManager.setText(
text = totpResult.code,
toastDescriptorOverride = R.string.verification_code_totp.asText(),
toastDescriptorOverride = BitwardenString.verification_code_totp.asText(),
)
}
}

View File

@@ -2,19 +2,14 @@ package com.x8bit.bitwarden.data.autofill.manager.browser
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutoFillData
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
/**
* Default implementation of [BrowserThirdPartyAutofillEnabledManager].
*/
class BrowserThirdPartyAutofillEnabledManagerImpl(
private val featureFlagManager: FeatureFlagManager,
) : BrowserThirdPartyAutofillEnabledManager {
class BrowserThirdPartyAutofillEnabledManagerImpl : BrowserThirdPartyAutofillEnabledManager {
override var browserThirdPartyAutofillStatus: BrowserThirdPartyAutofillStatus = DEFAULT_STATUS
set(value) {
field = value
@@ -29,15 +24,6 @@ class BrowserThirdPartyAutofillEnabledManagerImpl(
override val browserThirdPartyAutofillStatusFlow: Flow<BrowserThirdPartyAutofillStatus>
get() = mutableBrowserThirdPartyAutofillStatusStateFlow
.combine(
featureFlagManager.getFeatureFlagFlow(FlagKey.ChromeAutofill),
) { data, enabled ->
if (enabled) {
data
} else {
DEFAULT_STATUS
}
}
}
private val DEFAULT_STATUS = BrowserThirdPartyAutofillStatus(

View File

@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.autofill.model
/**
* Autofill hints used to determine what data an input field is associated with.
*/
enum class AutofillHint {
CARD_CARDHOLDER,
CARD_EXPIRATION_DATE,
CARD_EXPIRATION_MONTH,
CARD_EXPIRATION_YEAR,
CARD_NUMBER,
CARD_SECURITY_CODE,
PASSWORD,
USERNAME,
}

View File

@@ -54,6 +54,20 @@ sealed class AutofillView {
override val data: Data,
) : Card()
/**
* The expiration date [AutofillView] for the [Card] data partition.
*/
data class ExpirationDate(
override val data: Data,
) : Card()
/**
* The cardholder name [AutofillView] for the [Card] data partition.
*/
data class CardholderName(
override val data: Data,
) : Card()
/**
* The number [AutofillView] for the [Card] data partition.
*/

View File

@@ -1,16 +1,19 @@
package com.x8bit.bitwarden.data.autofill.provider
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import timber.log.Timber
/**
* The duration, in milliseconds, we should wait while waiting for the vault status to not be
@@ -49,31 +52,35 @@ class AutofillCipherProviderImpl(
}
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
val cipherListViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
return cipherViews
.mapNotNull { cipherView ->
cipherView
return cipherListViews
.mapNotNull { cipherListView ->
cipherListView
// We only care about non-deleted card ciphers.
.takeIf {
// Must be card type.
cipherView.type == CipherType.CARD &&
it.type is CipherListViewType.Card &&
// Must not be deleted.
cipherView.deletedDate == null &&
it.deletedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE
}
?.let { nonNullCipherView ->
AutofillCipher.Card(
cipherId = cipherView.id,
name = nonNullCipherView.name,
subtitle = nonNullCipherView.subtitle.orEmpty(),
cardholderName = nonNullCipherView.card?.cardholderName.orEmpty(),
code = nonNullCipherView.card?.code.orEmpty(),
expirationMonth = nonNullCipherView.card?.expMonth.orEmpty(),
expirationYear = nonNullCipherView.card?.expYear.orEmpty(),
number = nonNullCipherView.card?.number.orEmpty(),
)
?.let { nonNullCipherListView ->
nonNullCipherListView.id?.let { cipherId ->
decryptCipherOrNull(cipherId = cipherId)?.let { cipherView ->
AutofillCipher.Card(
cipherId = cipherView.id,
name = cipherView.name,
subtitle = cipherView.subtitle.orEmpty(),
cardholderName = cipherView.card?.cardholderName.orEmpty(),
code = cipherView.card?.code.orEmpty(),
expirationMonth = cipherView.card?.expMonth.orEmpty(),
expirationYear = cipherView.card?.expYear.orEmpty(),
number = cipherView.card?.number.orEmpty(),
)
}
}
}
}
}
@@ -81,12 +88,12 @@ class AutofillCipherProviderImpl(
override suspend fun getLoginAutofillCiphers(
uri: String,
): List<AutofillCipher.Login> {
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
val cipherViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
// We only care about non-deleted login ciphers.
val loginCiphers = cipherViews
.filter {
// Must be login type
it.type == CipherType.LOGIN &&
it.type is CipherListViewType.Login &&
// Must not be deleted.
it.deletedDate == null &&
// Must not require a reprompt.
@@ -96,9 +103,12 @@ class AutofillCipherProviderImpl(
return cipherMatchingManager
// Filter for ciphers that match the uri in some way.
.filterCiphersForMatches(
ciphers = loginCiphers,
cipherListViews = loginCiphers,
matchUri = uri,
)
.mapNotNull { cipherListView ->
cipherListView.id?.let { decryptCipherOrNull(cipherId = it) }
}
.map { cipherView ->
AutofillCipher.Login(
cipherId = cipherView.id,
@@ -114,10 +124,24 @@ class AutofillCipherProviderImpl(
/**
* Get available [CipherView]s if possible.
*/
private suspend fun getUnlockedCiphersOrNull(): List<CipherView>? =
private suspend fun getUnlockedCipherListViewsOrNull(): List<CipherListView>? =
vaultRepository
.ciphersStateFlow
.decryptCipherListResultStateFlow
.takeUnless { isVaultLocked() }
?.firstWithTimeoutOrNull(timeMillis = GET_CIPHERS_TIMEOUT_MS) { it.data != null }
?.data
?.successes
private suspend fun decryptCipherOrNull(cipherId: String): CipherView? =
when (val result = vaultRepository.getCipher(cipherId = cipherId)) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found for autofill.")
null
}
is GetCipherResult.Failure -> {
Timber.e(result.error, "Failed to decrypt cipher for autofill.")
null
}
is GetCipherResult.Success -> result.cipherView
}
}

View File

@@ -12,13 +12,13 @@ import android.service.autofill.Dataset
import android.view.autofill.AutofillManager
import androidx.core.os.bundleOf
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
import com.x8bit.bitwarden.AutofillTotpCopyActivity
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.model.AutofillTotpCopyData
import com.x8bit.bitwarden.data.platform.util.getSafeParcelableExtra
import kotlin.random.Random
private const val AUTOFILL_SAVE_ITEM_DATA_KEY = "autofill-save-item-data"

View File

@@ -0,0 +1,31 @@
package com.x8bit.bitwarden.data.autofill.util
import com.bitwarden.vault.CardListView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CopyableCipherFields
import com.bitwarden.vault.LoginListView
/**
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
*/
val CipherListView.isActiveWithFido2Credentials: Boolean
get() = deletedDate == null && login?.hasFido2 ?: false
/**
* Returns true when the cipher type is not deleted and contains a copyable password.
*/
val CipherListView.isActiveWithCopyablePassword: Boolean
get() = deletedDate == null && copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
/**
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.
*/
val CipherListView.login: LoginListView?
get() = (this.type as? CipherListViewType.Login)?.v1
/**
* Returns the [CardListView] if the cipher is of type [CipherListViewType.Card], otherwise null.
*/
val CipherListView.card: CardListView?
get() = (this.type as? CipherListViewType.Card)?.v1

View File

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

View File

@@ -1,53 +1,111 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.data.autofill.util
import android.view.ViewStructure.HtmlInfo
import com.bitwarden.annotation.OmitFromCoverage
/**
* Whether this [HtmlInfo] represents a password field.
*
* This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires
* instrumentation testing.
*/
@OmitFromCoverage
fun HtmlInfo?.isPasswordField(): Boolean =
this
?.let { htmlInfo ->
if (htmlInfo.isInputField) {
htmlInfo
.attributes
?.any {
it.first == "type" && it.second == "password"
}
} else {
false
}
}
?: false
fun HtmlInfo?.isPasswordField(): Boolean = isInputField &&
hints().containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS)
/**
* Whether this [HtmlInfo] represents a username field.
*/
fun HtmlInfo?.isUsernameField(): Boolean = isInputField &&
hints().containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS)
/**
* Whether this [HtmlInfo] represents a cardholder name field.
*/
fun HtmlInfo?.isCardholderNameField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS)
/**
* Whether this [HtmlInfo] represents a card number field.
*/
fun HtmlInfo?.isCardNumberField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS)
/**
* Whether this [HtmlInfo] represents a card expiration month field.
*/
fun HtmlInfo?.isCardExpirationMonthField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS)
/**
* Whether this [HtmlInfo] represents a card expiration year field.
*/
fun HtmlInfo?.isCardExpirationYearField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS)
/**
* Whether this [HtmlInfo] represents a card expiration date field.
*/
fun HtmlInfo?.isCardExpirationDateField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS)
/**
* Whether this [HtmlInfo] represents a card security code field.
*/
fun HtmlInfo?.isCardSecurityCodeField(): Boolean = isInputField &&
hints().containsAnyPatterns(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS)
/**
* Attributes that can be used as hints to determine the type of data the associated node expects.
*
* This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires
* instrumentation testing.
*
* @see IGNORED_RAW_HINTS
* @see SUPPORTED_HTML_ATTRIBUTE_HINTS
*/
@OmitFromCoverage
fun HtmlInfo?.isUsernameField(): Boolean =
this
?.let { htmlInfo ->
if (htmlInfo.isInputField) {
htmlInfo
.attributes
?.any {
it.first == "type" && it.second == "email"
}
} else {
false
fun HtmlInfo?.hints(): List<String> = this
?.let { htmlInfo ->
htmlInfo
.attributes
// Filter out attributes with null values or values that match ignored raw hints
?.filter { attribute ->
attribute.second != null &&
!attribute.second.containsAnyTerms(IGNORED_RAW_HINTS)
}
}
?: false
// Filter attributes that match supported HTML attribute hints
?.filter { attribute ->
attribute.first.containsAnyTerms(
terms = SUPPORTED_HTML_ATTRIBUTE_HINTS,
ignoreCase = true,
)
}
.orEmpty()
.mapNotNull { it.second }
}
.orEmpty()
/**
* Whether this [HtmlInfo] represents an input field.
*/
val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input"
/**
* Checks if the list of strings contains any of the specified patterns.
*/
private fun List<String>.containsAnyPatterns(patterns: List<Regex>): Boolean = this
.any { string -> patterns.any { pattern -> string.matches(pattern) } }
/**
* Checks if the list of strings contains any of the specified terms.
*/
private fun List<String>.containsAnyTerms(terms: List<String>): Boolean =
this.any { string -> string.containsAnyTerms(terms) }
/**
* The supported attribute keys whose value can represent an autofill hint.
*/
private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List<String> = listOf(
"name",
"label",
"type",
"hint",
"autofill",
)

View File

@@ -16,3 +16,13 @@ fun String.containsAnyTerms(
ignoreCase = ignoreCase,
)
}
/**
* Check whether this string matches any of these [expressions].
*/
fun String.matchesAnyExpressions(
expressions: List<Regex>,
): Boolean =
expressions.any {
this.matches(regex = it)
}

View File

@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import android.view.View
import android.widget.EditText
import androidx.annotation.VisibleForTesting
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.data.autofill.model.AutofillHint
import com.x8bit.bitwarden.data.autofill.model.AutofillView
/**
@@ -11,39 +13,13 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillView
*/
private const val DEFAULT_SCHEME: String = "https"
/**
* The set of raw autofill hints that should be ignored.
*/
private val IGNORED_RAW_HINTS: List<String> = listOf(
"search",
"find",
"recipient",
"edit",
)
/**
* The supported password autofill hints.
*/
private val SUPPORTED_RAW_PASSWORD_HINTS: List<String> = listOf(
"password",
"pswd",
)
/**
* The supported raw autofill hints.
*/
private val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"email",
"phone",
"username",
)
/**
* The supported autofill Android View hints.
*/
private val SUPPORTED_VIEW_HINTS: List<String> = listOf(
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE,
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER,
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
@@ -60,7 +36,7 @@ private val AssistStructure.ViewNode.isInputField: Boolean
?.let {
try {
Class.forName(it)
} catch (e: ClassNotFoundException) {
} catch (_: ClassNotFoundException) {
null
}
}
@@ -78,11 +54,7 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
.autofillId
// We only care about nodes with a valid `AutofillId`.
?.let { nonNullAutofillId ->
val supportedHint = this
.autofillHints
?.firstOrNull { SUPPORTED_VIEW_HINTS.contains(it) }
if (supportedHint != null || this.isInputField) {
if (supportedAutofillHint != null || this.isInputField) {
val autofillOptions = this
.autofillOptions
.orEmpty()
@@ -99,22 +71,63 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
buildAutofillView(
autofillOptions = autofillOptions,
autofillViewData = autofillViewData,
supportedHint = supportedHint,
autofillHint = supportedAutofillHint,
)
} else {
null
}
}
/**
* The first supported autofill hint for this view node, or null if none are found.
*/
private val AssistStructure.ViewNode.supportedAutofillHint: AutofillHint?
get() = firstSupportedAutofillHintOrNull()
?: when {
this.isUsernameField -> AutofillHint.USERNAME
this.isPasswordField -> AutofillHint.PASSWORD
this.isCardExpirationMonthField -> AutofillHint.CARD_EXPIRATION_MONTH
this.isCardExpirationYearField -> AutofillHint.CARD_EXPIRATION_YEAR
this.isCardExpirationDateField -> AutofillHint.CARD_EXPIRATION_DATE
this.isCardNumberField -> AutofillHint.CARD_NUMBER
this.isCardSecurityCodeField -> AutofillHint.CARD_SECURITY_CODE
this.isCardholderNameField -> AutofillHint.CARD_CARDHOLDER
else -> null
}
/**
* Get the first supported autofill hint from the view node's autofillHints, or null if none are
* found.
*/
private fun AssistStructure.ViewNode.firstSupportedAutofillHintOrNull(): AutofillHint? =
autofillHints
?.firstOrNull { SUPPORTED_VIEW_HINTS.contains(it) }
?.toBitwardenAutofillHintOrNull()
private fun String.toBitwardenAutofillHintOrNull(): AutofillHint? =
when (this) {
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> AutofillHint.CARD_EXPIRATION_MONTH
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> AutofillHint.CARD_EXPIRATION_YEAR
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> AutofillHint.CARD_EXPIRATION_DATE
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> AutofillHint.CARD_NUMBER
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> AutofillHint.CARD_SECURITY_CODE
View.AUTOFILL_HINT_PASSWORD -> AutofillHint.PASSWORD
View.AUTOFILL_HINT_EMAIL_ADDRESS,
View.AUTOFILL_HINT_USERNAME,
-> AutofillHint.USERNAME
else -> null
}
/**
* Attempt to convert this [AssistStructure.ViewNode] and [autofillViewData] into an [AutofillView].
*/
private fun AssistStructure.ViewNode.buildAutofillView(
autofillOptions: List<String>,
autofillViewData: AutofillView.Data,
supportedHint: String?,
): AutofillView = when {
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
autofillHint: AutofillHint?,
): AutofillView = when (autofillHint) {
AutofillHint.CARD_EXPIRATION_MONTH -> {
val monthValue = this
.autofillValue
?.extractMonthValue(
@@ -127,31 +140,43 @@ private fun AssistStructure.ViewNode.buildAutofillView(
)
}
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> {
AutofillHint.CARD_EXPIRATION_YEAR -> {
AutofillView.Card.ExpirationYear(
data = autofillViewData,
)
}
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> {
AutofillHint.CARD_EXPIRATION_DATE -> {
AutofillView.Card.ExpirationDate(
data = autofillViewData,
)
}
AutofillHint.CARD_NUMBER -> {
AutofillView.Card.Number(
data = autofillViewData,
)
}
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> {
AutofillHint.CARD_SECURITY_CODE -> {
AutofillView.Card.SecurityCode(
data = autofillViewData,
)
}
this.isPasswordField(supportedHint) -> {
AutofillHint.CARD_CARDHOLDER -> {
AutofillView.Card.CardholderName(
data = autofillViewData,
)
}
AutofillHint.PASSWORD -> {
AutofillView.Login.Password(
data = autofillViewData,
)
}
this.isUsernameField(supportedHint) -> {
AutofillHint.USERNAME -> {
AutofillView.Login.Username(
data = autofillViewData,
)
@@ -167,41 +192,97 @@ private fun AssistStructure.ViewNode.buildAutofillView(
/**
* Check whether this [AssistStructure.ViewNode] represents a password field.
*/
fun AssistStructure.ViewNode.isPasswordField(
supportedHint: String?,
): Boolean {
if (supportedHint == View.AUTOFILL_HINT_PASSWORD) return true
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val AssistStructure.ViewNode.isPasswordField: Boolean
get() {
val isUsernameField = this.isUsernameField
if (
this.inputType.isPasswordInputType &&
!this.containsIgnoredHintTerms() &&
!isUsernameField
) {
return true
}
val isInvalidField = this.idEntry?.containsAnyTerms(IGNORED_RAW_HINTS) == true ||
this.hint?.containsAnyTerms(IGNORED_RAW_HINTS) == true
val isUsernameField = this.isUsernameField(supportedHint)
if (this.inputType.isPasswordInputType && !isInvalidField && !isUsernameField) return true
return this
.htmlInfo
.isPasswordField()
}
return hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true ||
htmlInfo.isPasswordField()
}
/**
* Check whether this [AssistStructure.ViewNode] includes any password specific terms.
*/
fun AssistStructure.ViewNode.hasPasswordTerms(): Boolean =
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun AssistStructure.ViewNode.hasPasswordTerms(): Boolean =
this.idEntry?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true ||
this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true
this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true ||
this.htmlInfo.hints().any { it.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) }
/**
* Check whether this [AssistStructure.ViewNode] represents a username field.
*/
fun AssistStructure.ViewNode.isUsernameField(
supportedHint: String?,
): Boolean =
supportedHint == View.AUTOFILL_HINT_USERNAME ||
supportedHint == View.AUTOFILL_HINT_EMAIL_ADDRESS ||
inputType.isUsernameInputType ||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val AssistStructure.ViewNode.isUsernameField: Boolean
get() = inputType.isUsernameInputType ||
idEntry?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true ||
hint?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true ||
htmlInfo.isUsernameField()
/**
* Check whether this [AssistStructure.ViewNode] represents a card expiration month field.
*/
private val AssistStructure.ViewNode.isCardExpirationMonthField: Boolean
get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS) == true ||
htmlInfo.isCardExpirationMonthField()
/**
* Check whether this [AssistStructure.ViewNode] represents a card expiration year field.
*/
private val AssistStructure.ViewNode.isCardExpirationYearField: Boolean
get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS) == true ||
htmlInfo.isCardExpirationYearField()
/**
* Check whether this [AssistStructure.ViewNode] represents a card expiration date field.
*/
private val AssistStructure.ViewNode.isCardExpirationDateField: Boolean
get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS) == true ||
htmlInfo.isCardExpirationDateField()
/**
* Check whether this [AssistStructure.ViewNode] represents a card number field based.
*/
private val AssistStructure.ViewNode.isCardNumberField: Boolean
get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS) == true ||
htmlInfo.isCardNumberField()
/**
* Check whether this [AssistStructure.ViewNode] represents a card security code field based.
*/
private val AssistStructure.ViewNode.isCardSecurityCodeField: Boolean
get() =
idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS) == true ||
htmlInfo.isCardSecurityCodeField()
/**
* Check whether this [AssistStructure.ViewNode] represents a cardholder name field based.
*/
private val AssistStructure.ViewNode.isCardholderNameField: Boolean
get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS) == true ||
hint?.matchesAnyExpressions(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS) == true ||
htmlInfo.isCardholderNameField()
/**
* Check whether this [AssistStructure.ViewNode] contains any ignored hint terms.
*/
private fun AssistStructure.ViewNode.containsIgnoredHintTerms(): Boolean =
this.idEntry?.containsAnyTerms(IGNORED_RAW_HINTS) == true ||
this.hint?.containsAnyTerms(IGNORED_RAW_HINTS) == true ||
this.htmlInfo.hints().any { it.containsAnyTerms(IGNORED_RAW_HINTS) }
/**
* The website that this [AssistStructure.ViewNode] is a part of representing.
*/

View File

@@ -0,0 +1,137 @@
package com.x8bit.bitwarden.data.autofill.util
/**
* The set of raw autofill hints that should be ignored.
*/
val IGNORED_RAW_HINTS: List<String> = listOf(
"search",
"find",
"recipient",
"edit",
)
/**
* The supported password autofill hints.
*/
val SUPPORTED_RAW_PASSWORD_HINTS: List<String> = listOf(
"password",
"pswd",
)
/**
* The supported raw autofill hints.
*/
val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"email",
"phone",
"username",
)
/**
* Matches common patterns for cardholder name hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - `(?:cc|card)[\\s_-](?:name|cardholder).*`: Matches "cc" or "card" followed by a space,
* underscore, or hyphen, then "name" or "cardholder", and finally any characters. This covers
* variations like "cc name", "card_cardholder", "credit-card-name something else".
* - `|`: OR operator, allowing for an alternative pattern.
* - `name[\\s_-]on[\\s_-]card`: Matches "name" followed by a space, underscore, or hyphen, then
* "on", another space, underscore, or hyphen, and finally "card". This covers phrases like "name on
* card" or "name_on_card".
* - `\b`: Word boundary to ensure we match whole words.
*/
val SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:cc|card)[\\s_-](?:name|cardholder).*\\b".toRegex(),
"\\b(?i)name[\\s_-]on[\\s_-]card\\b".toRegex(),
)
/**
* Matches common patterns for card number hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - `(?:cc|card)`: Matches "cc" or "card".
* - `[\\s_-]number`: Matches "number" preceded by a space, underscore, or hyphen.
* - `\b`: Word boundary to ensure we match whole words.
*/
val SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:cc|card)[\\s_-]number\\b".toRegex(),
)
/**
* Matches common patterns for card expiration month hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - `(?:cc|card)`: Matches "cc" or "card".
* - `[\\s_-]exp[\\s_-]month`: Matches "exp" followed by a space, underscore, or hyphen, then
* "month".
* - `\b`: Word boundary to ensure we match whole words.
*
* Examples:
* - "credit card exp month"
* - "cc_exp_month"
* - "card-exp-month"
*/
val SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]month\\b"
.toRegex(),
)
/**
* Matches common patterns for card expiration year hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - `(?:cc|card)`: Matches "cc" or "card".
* - `[\\s_-]exp[\\s_-]year`: Matches "exp" followed by a space, underscore, or hyphen, then "year".
* - `\b`: Word boundary to ensure we match whole words.
*
* Similar to [SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS], but for "year" instead of "month".
* @see SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS
*/
val SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]year\\b"
.toRegex(),
)
/**
* Matches common patterns for card expiration date hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - `(?:cc|card)`: Matches "cc" or "card".
* - `[\\s_-]exp[\\s_-]date`: Matches "exp" followed by a space, underscore, or hyphen, then "date".
* - `.*`: Matches any characters following "date" (e.g., "MM/YY", "month/year").
* - `\b`: Word boundary to ensure we match whole words.
*
* Examples:
* - "credit card exp date"
* - "cc_exp_date_mm_yy"
* - "card-exp-date month/year"
*/
val SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]date\\b"
.toRegex(),
)
/**
* Matches common patterns for card security code hints.
* - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words.
* - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen.
* - The first pattern `(?:cc|card[\\s_-])(cvc|cvv)\b`:
* - `(?:cc|card[\\s_-])`: Matches "cc" or "card" followed by a space, underscore, or hyphen.
* - `(cvc|cvv)\b`: Matches "cvc" or "cvv" followed by a word boundary.
* - The second pattern `(?:cc|card)(?:[\\s_-]verification)?([\\s_-]code)\b`:
* - `(?:cc|card)`: Matches "cc" or "card".
* - `(?:[\\s_-]verification)?`: Optionally matches "verification" preceded by a space,
* underscore, or hyphen.
* - `([\\s_-]code)\b`: Matches "code" preceded by a space, underscore, or hyphen, and
* followed by a word boundary.
*
* Examples:
* - "credit card cvc"
* - "cc_verification_code"
* - "card-code"
*/
val SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS: List<Regex> = listOf(
"\\b(?i)(?:credit[\\s_-])?(?:cc|card[\\s_-])?(cvc|cvv)2?\\b".toRegex(),
"\\b(?i)(?:credit[\\s_-])?(?:cc|card)(?:[\\s_-](?:verification|security))?([\\s_-]code)\\b"
.toRegex(),
)

View File

@@ -1,8 +1,11 @@
package com.x8bit.bitwarden.data.credentials.builder
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.vault.CipherListView
/**
* Builder for credential entries.
@@ -18,4 +21,14 @@ interface CredentialEntryBuilder {
beginGetPublicKeyCredentialOptions: List<BeginGetPublicKeyCredentialOption>,
isUserVerified: Boolean,
): List<PublicKeyCredentialEntry>
/**
* Build password credential entries from the given cipher views and options.
*/
fun buildPasswordCredentialEntries(
userId: String,
cipherListViews: List<CipherListView>,
beginGetPasswordCredentialOptions: List<BeginGetPasswordOption>,
isUserVerified: Boolean,
): List<PasswordCredentialEntry>
}

View File

@@ -3,26 +3,25 @@ package com.x8bit.bitwarden.data.credentials.builder
import android.content.Context
import android.graphics.drawable.Icon
import androidx.core.graphics.drawable.IconCompat
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.credentials.processor.GET_PASSKEY_INTENT
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.vault.CipherListView
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupported
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlin.random.Random
/**
* Primary implementation of [CredentialEntryBuilder].
*/
class CredentialEntryBuilderImpl(
private val context: Context,
private val intentManager: IntentManager,
private val featureFlagManager: FeatureFlagManager,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
) : CredentialEntryBuilder {
@@ -41,6 +40,21 @@ class CredentialEntryBuilderImpl(
)
}
override fun buildPasswordCredentialEntries(
userId: String,
cipherListViews: List<CipherListView>,
beginGetPasswordCredentialOptions: List<BeginGetPasswordOption>,
isUserVerified: Boolean,
): List<PasswordCredentialEntry> = beginGetPasswordCredentialOptions
.flatMap { option ->
cipherListViews
.toPasswordCredentialEntryList(
userId = userId,
option = option,
isUserVerified = isUserVerified,
)
}
private fun List<Fido2CredentialAutofillView>.toPublicKeyCredentialEntryList(
userId: String,
option: BeginGetPublicKeyCredentialOption,
@@ -51,16 +65,13 @@ class CredentialEntryBuilderImpl(
.Builder(
context = context,
username = fido2AutofillView.userNameForUi
?: context.getString(R.string.no_username),
pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
userId = userId,
credentialId = fido2AutofillView.credentialId.toString(),
cipherId = fido2AutofillView.cipherId,
isUserVerified = isUserVerified,
requestCode = Random.nextInt(),
),
?: context.getString(BitwardenString.no_username),
pendingIntent = pendingIntentManager.createFido2GetCredentialPendingIntent(
userId = userId,
credentialId = fido2AutofillView.credentialId.toString(),
cipherId = fido2AutofillView.cipherId,
isUserVerified = isUserVerified,
),
beginGetPublicKeyCredentialOption = option,
)
.setIcon(
@@ -71,10 +82,39 @@ class CredentialEntryBuilderImpl(
.also { builder ->
if (!isUserVerified) {
builder.setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
)
}
}
.build()
}
private fun List<CipherListView>.toPasswordCredentialEntryList(
userId: String,
option: BeginGetPasswordOption,
isUserVerified: Boolean,
): List<PasswordCredentialEntry> = this
.map { cipherView ->
PasswordCredentialEntry
.Builder(
context = context,
username = cipherView.login?.username
?: context.getString(BitwardenString.no_username),
pendingIntent = pendingIntentManager.createPasswordGetCredentialPendingIntent(
userId = userId,
cipherId = cipherView.id,
isUserVerified = isUserVerified,
),
beginGetPasswordOption = option,
)
.setDisplayName(cipherView.name)
.setAutoSelectAllowed(this.size == 1)
.setIcon(getCredentialEntryIcon())
.apply {
if (!isUserVerified) {
setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager
.getOrCreateCipher(userId),
isSingleTapAuthEnabled = featureFlagManager
.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication),
)
}
}
@@ -84,13 +124,14 @@ class CredentialEntryBuilderImpl(
// TODO: [PM-20176] Enable web icons in credential entries
// Leave web icons disabled until CredentialManager TransactionTooLargeExceptions
// are addressed. See https://issuetracker.google.com/issues/355141766 for details.
private fun getCredentialEntryIcon(isPasskey: Boolean): Icon = IconCompat
private fun getCredentialEntryIcon(
isPasskey: Boolean = false,
): Icon = IconCompat
.createWithResource(
context,
if (isPasskey) {
BitwardenDrawable.ic_bw_passkey
} else {
BitwardenDrawable.ic_globe
when {
isPasskey -> BitwardenDrawable.ic_bw_passkey
else -> BitwardenDrawable.ic_globe
},
)
.toIcon(context)

View File

@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilderImpl
import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
@@ -23,9 +25,9 @@ import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepositoryIm
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
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -50,20 +52,18 @@ object CredentialProviderModule {
authRepository: AuthRepository,
bitwardenCredentialManager: BitwardenCredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
pendingIntentManager: CredentialManagerPendingIntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
featureFlagManager: FeatureFlagManager,
clock: Clock,
): CredentialProviderProcessor =
CredentialProviderProcessorImpl(
context,
authRepository,
bitwardenCredentialManager,
intentManager,
clock,
biometricsEncryptionManager,
featureFlagManager,
dispatcherManager,
context = context,
authRepository = authRepository,
bitwardenCredentialManager = bitwardenCredentialManager,
pendingIntentManager = pendingIntentManager,
clock = clock,
biometricsEncryptionManager = biometricsEncryptionManager,
dispatcherManager = dispatcherManager,
)
@Provides
@@ -75,6 +75,7 @@ object CredentialProviderModule {
vaultRepository: VaultRepository,
dispatcherManager: DispatcherManager,
credentialEntryBuilder: CredentialEntryBuilder,
cipherMatchingManager: CipherMatchingManager,
): BitwardenCredentialManager =
BitwardenCredentialManagerImpl(
vaultSdkSource = vaultSdkSource,
@@ -83,6 +84,7 @@ object CredentialProviderModule {
vaultRepository = vaultRepository,
dispatcherManager = dispatcherManager,
credentialEntryBuilder = credentialEntryBuilder,
cipherMatchingManager = cipherMatchingManager,
)
@Provides
@@ -104,13 +106,11 @@ object CredentialProviderModule {
@Singleton
fun provideCredentialEntryBuilder(
@ApplicationContext context: Context,
intentManager: IntentManager,
featureFlagManager: FeatureFlagManager,
pendingIntentManager: CredentialManagerPendingIntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
): CredentialEntryBuilder = CredentialEntryBuilderImpl(
context = context,
intentManager = intentManager,
featureFlagManager = featureFlagManager,
pendingIntentManager = pendingIntentManager,
biometricsEncryptionManager = biometricsEncryptionManager,
)
@@ -133,4 +133,13 @@ object CredentialProviderModule {
fun provideRelyingPartyParser(
json: Json,
): RelyingPartyParser = RelyingPartyParserImpl(json)
@Provides
@Singleton
fun provideCredentialManagerPendingIntentManager(
@ApplicationContext context: Context,
): CredentialManagerPendingIntentManager =
CredentialManagerPendingIntentManagerImpl(
context = context,
)
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.manager
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.provider.CredentialEntry
@@ -18,8 +19,12 @@ import com.bitwarden.fido.Origin
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessaryOrNull
import com.bitwarden.ui.platform.base.util.toAndroidAppUriString
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.util.isActiveWithCopyablePassword
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult
@@ -27,6 +32,7 @@ import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.credentials.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
@@ -35,8 +41,8 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2Cred
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidAttestationResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.withContext
@@ -53,6 +59,7 @@ class BitwardenCredentialManagerImpl(
private val credentialEntryBuilder: CredentialEntryBuilder,
private val json: Json,
private val vaultRepository: VaultRepository,
private val cipherMatchingManager: CipherMatchingManager,
dispatcherManager: DispatcherManager,
) : BitwardenCredentialManager,
Fido2CredentialStore by fido2CredentialStore {
@@ -168,30 +175,48 @@ class BitwardenCredentialManagerImpl(
override suspend fun getCredentialEntries(
getCredentialsRequest: GetCredentialsRequest,
): Result<List<CredentialEntry>> = withContext(ioScope.coroutineContext) {
val cipherViews = vaultRepository
.ciphersStateFlow
val cipherListViews = vaultRepository
.decryptCipherListResultStateFlow
.takeUntilLoaded()
.fold(initial = emptyList<CipherView>()) { _, dataState ->
.fold(initial = emptyList<CipherListView>()) { _, dataState ->
when (dataState) {
is DataState.Loaded -> {
dataState.data
}
is DataState.Loaded -> dataState.data.successes
else -> emptyList()
}
}
.filter { it.isActiveWithFido2Credentials }
.ifEmpty {
return@withContext emptyList<CredentialEntry>().asSuccess()
}
.filter { it.isActiveWithFido2Credentials || it.isActiveWithCopyablePassword }
.ifEmpty { return@withContext emptyList<CredentialEntry>().asSuccess() }
getCredentialsRequest
val passwordCredentialResult = getCredentialsRequest
.callingAppInfo
?.packageName
?.let { packageName ->
getCredentialsRequest
.beginGetPasswordOptions
.toPasswordCredentialEntries(
userId = getCredentialsRequest.userId,
cipherListViews = cipherMatchingManager.filterCiphersForMatches(
cipherListViews = cipherListViews,
matchUri = packageName.toAndroidAppUriString(),
),
)
}
.orEmpty()
val passkeyCredentialResult = getCredentialsRequest
.beginGetPublicKeyCredentialOptions
.toPublicKeyCredentialEntries(
userId = getCredentialsRequest.userId,
cipherViewsWithPublicKeyCredentials = cipherViews,
cipherListViews = cipherListViews
.filter { it.isActiveWithFido2Credentials },
)
.onFailure { Timber.e(it, "Failed to get FIDO 2 credential entries.") }
if (passkeyCredentialResult.isFailure && passwordCredentialResult.isNotEmpty()) {
Result.success(passwordCredentialResult)
} else {
passkeyCredentialResult.map { it + passwordCredentialResult }
}
}
private fun getPasskeyAssertionOptionsOrNull(
@@ -200,8 +225,10 @@ class BitwardenCredentialManagerImpl(
private suspend fun List<BeginGetPublicKeyCredentialOption>.toPublicKeyCredentialEntries(
userId: String,
cipherViewsWithPublicKeyCredentials: List<CipherView>,
cipherListViews: List<CipherListView>,
): Result<List<CredentialEntry>> {
if (this.isEmpty()) return emptyList<CredentialEntry>().asSuccess()
val relyingPartyIds = this
.mapNotNull { getPasskeyAssertionOptionsOrNull(it.requestJson)?.relyingPartyId }
.distinct()
@@ -209,27 +236,54 @@ class BitwardenCredentialManagerImpl(
return GetCredentialUnknownException("Relying party id required.").asFailure()
}
val decryptResult = vaultRepository
.getDecryptedFido2CredentialAutofillViews(cipherViewsWithPublicKeyCredentials)
return when (decryptResult) {
is DecryptFido2CredentialAutofillViewResult.Error -> {
GetCredentialUnknownException("Error decrypting credentials.").asFailure()
val cipherViews = cipherListViews
.filter { cipherListView ->
cipherListView.login
?.fido2Credentials
.orEmpty()
.any { credential -> credential.rpId in relyingPartyIds }
}
.mapNotNull { cipherListView ->
when (val result = vaultRepository.getCipher(cipherListView.id.orEmpty())) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found while building public key credential entries.")
null
}
is DecryptFido2CredentialAutofillViewResult.Success -> {
credentialEntryBuilder
.buildPublicKeyCredentialEntries(
userId = userId,
fido2CredentialAutofillViews = decryptResult
.fido2CredentialAutofillViews
.filter { it.rpId in relyingPartyIds },
beginGetPublicKeyCredentialOptions = this,
isUserVerified = isUserVerified,
)
.asSuccess()
is GetCipherResult.Failure -> {
Timber.e(
result.error,
"Failed to decrypt cipher while building credential entries.",
)
null
}
is GetCipherResult.Success -> result.cipherView
}
}
}
.toTypedArray()
.ifEmpty { return emptyList<CredentialEntry>().asSuccess() }
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
userId = userId,
cipherViews = cipherViews,
)
.fold(
onSuccess = { fido2AutofillViews ->
credentialEntryBuilder
.buildPublicKeyCredentialEntries(
userId = userId,
fido2CredentialAutofillViews = fido2AutofillViews,
beginGetPublicKeyCredentialOptions = this,
isUserVerified = isUserVerified,
)
.asSuccess()
},
onFailure = {
GetCredentialUnknownException("Error decrypting credentials.").asFailure()
},
)
}
private suspend fun registerFido2CredentialForUnprivilegedApp(
@@ -321,6 +375,21 @@ class BitwardenCredentialManagerImpl(
},
)
private fun List<BeginGetPasswordOption>.toPasswordCredentialEntries(
userId: String,
cipherListViews: List<CipherListView>,
): List<CredentialEntry> {
if (this.isEmpty()) return emptyList()
return credentialEntryBuilder
.buildPasswordCredentialEntries(
userId = userId,
cipherListViews = cipherListViews,
beginGetPasswordCredentialOptions = this,
isUserVerified = isUserVerified,
)
}
private fun getOriginUrlFromAssertionOptionsOrNull(requestJson: String) =
getPasskeyAssertionOptionsOrNull(requestJson)
?.relyingPartyId

View File

@@ -0,0 +1,69 @@
package com.x8bit.bitwarden.data.credentials.manager
import android.app.PendingIntent
/**
* Key for the user id included in Credential provider "create entries".
*
* @see CredentialManagerPendingIntentManager.createFido2CreationPendingIntent
*/
const val EXTRA_KEY_USER_ID: String = "user_id"
/**
* Key for the credential id included in FIDO 2 provider "get entries".
*
* @see CredentialManagerPendingIntentManager.createFido2GetCredentialPendingIntent
*/
const val EXTRA_KEY_CREDENTIAL_ID: String = "credential_id"
/**
* Key for the cipher id included in FIDO 2 provider "get entries".
*
* @see CredentialManagerPendingIntentManager.createFido2GetCredentialPendingIntent
*/
const val EXTRA_KEY_CIPHER_ID: String = "cipher_id"
/**
* Key for the user verification performed during vault unlock while
* processing a Credential request.
*/
const val EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK: String = "uv_performed_during_unlock"
/**
* A manager class for creating pending intents used in credential management operations.
*/
interface CredentialManagerPendingIntentManager {
/**
* Creates a pending intent to use when providing options for FIDO 2 credential creation.
*/
fun createFido2CreationPendingIntent(
userId: String,
): PendingIntent
/**
* Creates a pending intent to use when providing options for FIDO 2 credential filling.
*/
fun createFido2GetCredentialPendingIntent(
userId: String,
credentialId: String,
cipherId: String,
isUserVerified: Boolean,
): PendingIntent
/**
* Creates a pending intent to use when providing unlock options for FIDO 2 credential filling.
*/
fun createFido2UnlockPendingIntent(
userId: String,
): PendingIntent
/**
* Creates a pending intent to use when providing options for Password credential filling.
*/
fun createPasswordGetCredentialPendingIntent(
userId: String,
cipherId: String?,
isUserVerified: Boolean,
): PendingIntent
}

View File

@@ -0,0 +1,104 @@
package com.x8bit.bitwarden.data.credentials.manager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
import kotlin.random.Random
/**
* Primary implementation of [CredentialManagerPendingIntentManager].
*/
@OmitFromCoverage
class CredentialManagerPendingIntentManagerImpl(
private val context: Context,
) : CredentialManagerPendingIntentManager {
/**
* Creates a pending intent to use when providing options for FIDO 2 credential creation.
*/
override fun createFido2CreationPendingIntent(
userId: String,
): PendingIntent {
val intent = Intent(CREATE_PASSKEY_ACTION)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ Random.nextInt(),
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
/**
* Creates a pending intent to use when providing options for FIDO 2 credential filling.
*/
override fun createFido2GetCredentialPendingIntent(
userId: String,
credentialId: String,
cipherId: String,
isUserVerified: Boolean,
): PendingIntent {
val intent = Intent(GET_PASSKEY_ACTION)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ Random.nextInt(),
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
/**
* Creates a pending intent to use when providing unlock options for FIDO 2 credential filling.
*/
override fun createFido2UnlockPendingIntent(
userId: String,
): PendingIntent {
val intent = Intent(UNLOCK_ACCOUNT_ACTION)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ Random.nextInt(),
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
/**
* Creates a pending intent to use when providing options for Password credential filling.
*/
override fun createPasswordGetCredentialPendingIntent(
userId: String,
cipherId: String?,
isUserVerified: Boolean,
): PendingIntent {
val intent = Intent(GET_PASSWORD_ACTION)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ Random.nextInt(),
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
}
private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
private const val GET_PASSWORD_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD"

View File

@@ -1,13 +1,13 @@
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.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.credentials.model
import android.os.Bundle
import android.os.Parcelable
import androidx.credentials.CredentialManager
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
@@ -10,7 +11,7 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
/**
* Models a FIDO 2 request to retrieve FIDO credentials parsed from the launching intent.
* Models a [CredentialManager] request to retrieve credentials parsed from the launching intent.
*
* @param userId The ID of the user's vault to search.
* @param requestData Provider request data in the form of a [Bundle].

View File

@@ -2,22 +2,51 @@ package com.x8bit.bitwarden.data.credentials.model
import android.os.Bundle
import android.os.Parcelable
import androidx.credentials.CredentialOption
import androidx.credentials.GetPasswordOption
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.provider.ProviderGetCredentialRequest
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
/**
* A wrapper around [ProviderGetCredentialRequest] that includes additional information needed to
* fulfill the request.
* Models a Password credential authentication request parsed from the launching intent.
*
* @param userId The ID of the user that owns the credential being requested.
* @param cipherId The ID of the cipher containing the password to be retrieved.
* @param isUserVerified Whether the user has been verified prior to this request.
* @param requestData The original request data from the system.
* @property userId ID of the user requesting credential authentication.
* @property cipherId ID of the cipher to be authenticated against.
* @property isUserPreVerified Whether the user has already been verified by the OS biometric
* prompt.
* @property requestData Provider request data in the form of a [Bundle].
*/
@Parcelize
data class ProviderGetPasswordCredentialRequest(
val userId: String,
val cipherId: String,
val isUserVerified: Boolean,
val requestData: Bundle,
) : Parcelable
val isUserPreVerified: Boolean,
private val requestData: Bundle,
) : Parcelable {
/**
* The [ProviderGetCredentialRequest] from the [requestData].
*/
@IgnoredOnParcel
val providerRequest: ProviderGetCredentialRequest by lazy {
ProviderGetCredentialRequest.fromBundle(requestData)
}
/**
* The [CallingAppInfo] from the [providerRequest].
*/
@IgnoredOnParcel
val callingAppInfo: CallingAppInfo by lazy { providerRequest.callingAppInfo }
/**
* The [CredentialOption] from the [providerRequest], or null if one is not found
* in the request options list.
*/
@IgnoredOnParcel
val option: GetPasswordOption? by lazy {
providerRequest.credentialOptions
.firstNotNullOfOrNull { it as? GetPasswordOption }
}
}

View File

@@ -27,25 +27,18 @@ import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.R
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
/**
* The default implementation of [CredentialProviderProcessor]. Its purpose is to handle
* [CredentialManager] requests from other applications.
@@ -56,14 +49,12 @@ class CredentialProviderProcessorImpl(
private val context: Context,
private val authRepository: AuthRepository,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val intentManager: IntentManager,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val clock: Clock,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
) : CredentialProviderProcessor {
private val requestCode = AtomicInteger()
private val ioScope = CoroutineScope(dispatcherManager.io)
override fun processCreateCredentialRequest(
@@ -105,11 +96,9 @@ class CredentialProviderProcessorImpl(
// Return an unlock action if the current account is locked.
if (!userState.activeAccount.isVaultUnlocked) {
val authenticationAction = AuthenticationAction(
title = context.getString(R.string.unlock),
pendingIntent = intentManager.createFido2UnlockPendingIntent(
action = UNLOCK_ACCOUNT_INTENT,
title = context.getString(BitwardenString.unlock),
pendingIntent = pendingIntentManager.createFido2UnlockPendingIntent(
userId = userState.activeUserId,
requestCode = requestCode.getAndIncrement(),
),
)
@@ -184,15 +173,13 @@ class CredentialProviderProcessorImpl(
val entryBuilder = CreateEntry
.Builder(
accountName = accountName,
pendingIntent = intentManager.createFido2CreationPendingIntent(
CREATE_PASSKEY_INTENT,
userId,
requestCode.getAndIncrement(),
pendingIntent = pendingIntentManager.createFido2CreationPendingIntent(
userId = userId,
),
)
.setDescription(
context.getString(
R.string.your_passkey_will_be_saved_to_your_bitwarden_vault_for_x,
BitwardenString.your_passkey_will_be_saved_to_your_bitwarden_vault_for_x,
accountName,
),
)
@@ -201,9 +188,7 @@ class CredentialProviderProcessorImpl(
.setLastUsedTime(if (isActive) clock.instant() else null)
.setAutoSelectAllowed(true)
if (isVaultUnlocked &&
featureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyCreation)
) {
if (isVaultUnlocked) {
biometricsEncryptionManager
.getOrCreateCipher(userId)
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }

View File

@@ -3,6 +3,7 @@
package com.x8bit.bitwarden.data.credentials.util
import android.os.Build
import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
@@ -13,12 +14,22 @@ import javax.crypto.Cipher
*/
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
isSingleTapAuthEnabled: Boolean,
): PublicKeyCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
cipher != null &&
isSingleTapAuthEnabled
) {
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)
} else {
this
}
/**
* Sets the biometric prompt data on the [PasswordCredentialEntry.Builder] if supported.
*/
fun PasswordCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PasswordCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)

View File

@@ -8,13 +8,14 @@ import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_USER_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
/**
* Checks if this [Intent] contains a [CreateCredentialRequest] related to an ongoing
@@ -79,6 +80,38 @@ fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? {
)
}
/**
* Checks if this [Intent] contains a [ProviderGetPasswordCredentialRequest] related to an
* ongoing password credential GetPassword process.
*/
fun Intent.getProviderGetPasswordRequestOrNull(): ProviderGetPasswordCredentialRequest? {
if (!isBuildVersionAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
val systemRequest = PendingIntentHandler
.retrieveProviderGetCredentialRequest(this)
?: return null
val cipherId = getStringExtra(EXTRA_KEY_CIPHER_ID)
?: return null
val userId: String = getStringExtra(EXTRA_KEY_USER_ID)
?: return null
// Extract the OS biometric prompt result from the request data because it is not included in
// the bundle returned by `ProviderGetCredentialRequest.asBundle()`.
val isUserPreVerified = systemRequest
.biometricPromptResult
?.isSuccessful
?: getBooleanExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, false)
return ProviderGetPasswordCredentialRequest(
userId = userId,
cipherId = cipherId,
isUserPreVerified = isUserPreVerified,
requestData = ProviderGetCredentialRequest.asBundle(systemRequest),
)
}
/**
* Checks if this [Intent] contains a [GetCredentialsRequest] related to an ongoing
* [CredentialManager] credential lookup process.

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.bitwarden.core.data.manager.model.FlagKey
/**
* Disk data source for saved feature flag overrides.

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.datasource.disk.BaseDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
/**
* Default implementation of the [FeatureFlagOverrideDiskSource]

View File

@@ -7,6 +7,7 @@ import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import timber.log.Timber
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.KeyStore
@@ -45,9 +46,11 @@ class BiometricsEncryptionManagerImpl(
}
val cipher = try {
Cipher.getInstance(CIPHER_TRANSFORMATION)
} catch (_: NoSuchAlgorithmException) {
} catch (nsae: NoSuchAlgorithmException) {
Timber.w(nsae, "createCipherOrNull failed to get cipher instance")
return null
} catch (_: NoSuchPaddingException) {
} catch (nspe: NoSuchPaddingException) {
Timber.w(nspe, "createCipherOrNull failed to get cipher instance")
return null
}
// Instantiate integrity values.
@@ -124,20 +127,25 @@ class BiometricsEncryptionManagerImpl(
private fun generateKeyOrNull(userId: String): SecretKey? {
val keyGen = try {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ENCRYPTION_KEYSTORE_NAME)
} catch (_: NoSuchAlgorithmException) {
} catch (nsae: NoSuchAlgorithmException) {
Timber.w(nsae, "generateKeyOrNull failed to get key generator instance")
return null
} catch (_: NoSuchProviderException) {
} catch (nspe: NoSuchProviderException) {
Timber.w(nspe, "generateKeyOrNull failed to get key generator instance")
return null
} catch (_: IllegalArgumentException) {
} catch (iae: IllegalArgumentException) {
Timber.w(iae, "generateKeyOrNull failed to get key generator instance")
return null
}
return try {
keyGen.init(getKeyGenParameterSpec(userId = userId))
keyGen.generateKey()
} catch (_: InvalidAlgorithmParameterException) {
} catch (iape: InvalidAlgorithmParameterException) {
Timber.w(iape, "generateKeyOrNull failed to initialize and generate key")
null
} catch (_: ProviderException) {
} catch (pe: ProviderException) {
Timber.w(pe, "generateKeyOrNull failed to initialize and generate key")
null
}
}
@@ -150,14 +158,17 @@ class BiometricsEncryptionManagerImpl(
keystore
.getKey(encryptionKeyName(userId = userId), null)
?.let { it as SecretKey }
} catch (_: KeyStoreException) {
} catch (kse: KeyStoreException) {
// keystore was not loaded
Timber.w(kse, "getSecretKeyOrNull failed to retrieve secret key")
null
} catch (_: NoSuchAlgorithmException) {
} catch (nsae: NoSuchAlgorithmException) {
// keystore algorithm cannot be found
Timber.w(nsae, "getSecretKeyOrNull failed to retrieve secret key")
null
} catch (_: UnrecoverableKeyException) {
} catch (uke: UnrecoverableKeyException) {
// key could not be recovered
Timber.w(uke, "getSecretKeyOrNull failed to retrieve secret key")
null
}
@@ -174,16 +185,19 @@ class BiometricsEncryptionManagerImpl(
?.let { init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(it)) }
?: init(Cipher.ENCRYPT_MODE, secretKey)
true
} catch (_: KeyPermanentlyInvalidatedException) {
} catch (kpie: KeyPermanentlyInvalidatedException) {
// Biometric has changed
Timber.w(kpie, "initializeCipher failed to initialize cipher")
destroyBiometrics(userId = userId)
false
} catch (_: UnrecoverableKeyException) {
} catch (uke: UnrecoverableKeyException) {
// Biometric was disabled and re-enabled
Timber.w(uke, "initializeCipher failed to initialize cipher")
destroyBiometrics(userId = userId)
false
} catch (_: InvalidKeyException) {
} catch (ike: InvalidKeyException) {
// User has no key
Timber.w(ike, "initializeCipher failed to initialize cipher")
destroyBiometrics(userId = userId)
true
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.bitwarden.core.data.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.datasource.disk.model.ServerConfig
import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.util.isServerVersionAtLeast
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

View File

@@ -7,7 +7,6 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
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
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -31,7 +30,6 @@ class FirstTimeActionManagerImpl @Inject constructor(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val featureFlagManager: FeatureFlagManager,
private val autofillEnabledManager: AutofillEnabledManager,
) : FirstTimeActionManager {
@@ -101,16 +99,9 @@ class FirstTimeActionManagerImpl @Inject constructor(
.activeUserIdChangesFlow
.filterNotNull()
.flatMapLatest {
combine(
getShowImportLoginsSettingBadgeFlowInternal(userId = it),
featureFlagManager.getFeatureFlagFlow(FlagKey.ImportLoginsFlow),
) { showImportLogins, importLoginsEnabled ->
val shouldShowImportLoginsSettings = showImportLogins && importLoginsEnabled
listOf(shouldShowImportLoginsSettings)
}
.map { list ->
list.count { showImportLogins -> showImportLogins }
}
getShowImportLoginsSettingBadgeFlowInternal(userId = it)
.map { showImportLogins -> listOf(showImportLogins) }
.map { list -> list.count { showImportLogins -> showImportLogins } }
}
.stateIn(
scope = unconfinedScope,

View File

@@ -99,6 +99,7 @@ class PolicyManagerImpl(
PolicyTypeJson.PASSWORD_GENERATOR,
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN,
PolicyTypeJson.RESTRICT_ITEM_TYPES,
-> {
false
}

View File

@@ -1,16 +1,16 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CipherListView
/**
* A manager for matching ciphers based on special criteria.
*/
interface CipherMatchingManager {
/**
* Filter [ciphers] for entries that match the [matchUri] in some fashion.
* Filter [cipherListViews] for entries that match the [matchUri] in some fashion.
*/
suspend fun filterCiphersForMatches(
ciphers: List<CipherView>,
cipherListViews: List<CipherListView>,
matchUri: String,
): List<CipherView>
): List<CipherListView>
}

View File

@@ -1,8 +1,9 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.LoginUriView
import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
@@ -33,9 +34,9 @@ class CipherMatchingManagerImpl(
private val vaultRepository: VaultRepository,
) : CipherMatchingManager {
override suspend fun filterCiphersForMatches(
ciphers: List<CipherView>,
cipherListViews: List<CipherListView>,
matchUri: String,
): List<CipherView> {
): List<CipherListView> {
val equivalentDomainsData = vaultRepository
.domainsStateFlow
.mapNotNull { it.data }
@@ -58,14 +59,14 @@ class CipherMatchingManagerImpl(
matchUri = matchUri,
)
val exactMatchingCiphers = mutableListOf<CipherView>()
val fuzzyMatchingCiphers = mutableListOf<CipherView>()
val exactMatchingCiphers = mutableListOf<CipherListView>()
val fuzzyMatchingCiphers = mutableListOf<CipherListView>()
ciphers
.forEach { cipherView ->
cipherListViews
.forEach { cipherListView ->
val matchResult = checkForCipherMatch(
resourceCacheManager = resourceCacheManager,
cipherView = cipherView,
cipherListView = cipherListView,
defaultUriMatchType = defaultUriMatchType,
isAndroidApp = isAndroidApp,
matchUri = matchUri,
@@ -73,8 +74,8 @@ class CipherMatchingManagerImpl(
)
when (matchResult) {
MatchResult.EXACT -> exactMatchingCiphers.add(cipherView)
MatchResult.FUZZY -> fuzzyMatchingCiphers.add(cipherView)
MatchResult.EXACT -> exactMatchingCiphers.add(cipherListView)
MatchResult.FUZZY -> fuzzyMatchingCiphers.add(cipherListView)
MatchResult.NONE -> Unit
}
}
@@ -135,10 +136,10 @@ private fun getMatchingDomains(
}
/**
* Check to see if [cipherView] matches [matchUri] in some way. The returned [MatchResult] will
* Check to see if [cipherListView] matches [matchUri] in some way. The returned [MatchResult] will
* provide details on the match quality.
*
* @param cipherView The cipher to be judged for a match.
* @param cipherListView The cipher to be judged for a match.
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
@@ -148,13 +149,13 @@ private fun getMatchingDomains(
@Suppress("LongParameterList")
private fun checkForCipherMatch(
resourceCacheManager: ResourceCacheManager,
cipherView: CipherView,
cipherListView: CipherListView,
defaultUriMatchType: UriMatchType,
isAndroidApp: Boolean,
matchingDomains: MatchingDomains,
matchUri: String,
): MatchResult {
val matchResults = cipherView
val matchResults = cipherListView
.login
?.uris
?.map { loginUriView ->

View File

@@ -15,8 +15,8 @@ import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.base.util.toAnnotatedString
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import java.util.concurrent.TimeUnit
@@ -54,10 +54,10 @@ class BitwardenClipboardManagerImpl(
)
if (!isBuildVersionAtLeast(version = Build.VERSION_CODES.TIRAMISU)) {
val descriptor = toastDescriptorOverride
?.let { context.resources.getString(R.string.value_has_been_copied, it) }
?.let { context.resources.getString(BitwardenString.value_has_been_copied, it) }
?: context.resources.getString(
R.string.value_has_been_copied,
context.resources.getString(R.string.value),
BitwardenString.value_has_been_copied,
context.resources.getString(BitwardenString.value),
)
toastManager.show(message = descriptor)
}

View File

@@ -353,14 +353,12 @@ object PlatformManagerModule {
settingsDiskSource: SettingsDiskSource,
vaultDiskSource: VaultDiskSource,
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
autofillEnabledManager: AutofillEnabledManager,
): FirstTimeActionManager = FirstTimeActionManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
vaultDiskSource = vaultDiskSource,
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
autofillEnabledManager = autofillEnabledManager,
)

View File

@@ -73,6 +73,8 @@ class FlightRecorderWriterImpl(
bw.newLine()
bw.append("Device: ${Build.BRAND} ${Build.MODEL}")
bw.newLine()
bw.append("Fingerprint: ${Build.FINGERPRINT}")
bw.newLine()
}
}
logFile

View File

@@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.model.TotpData
import kotlinx.parcelize.Parcelize
@@ -70,7 +71,7 @@ sealed class SpecialCircumstance : Parcelable {
) : SpecialCircumstance()
/**
* The app was launched via the [CredentialManager] framework in order to authenticate a
* The app was launched via the [CredentialManager] framework in order to authenticate a FIDO 2
* credential saved to the user's vault.
*/
@Parcelize
@@ -78,6 +79,15 @@ sealed class SpecialCircumstance : Parcelable {
val fido2AssertionRequest: Fido2CredentialAssertionRequest,
) : SpecialCircumstance()
/**
* The app was launched via the [CredentialManager] framework in order to retrieve a Password
* credential saved to the user's vault.
*/
@Parcelize
data class ProviderGetPasswordRequest(
val passwordGetRequest: ProviderGetPasswordCredentialRequest,
) : SpecialCircumstance()
/**
* The app was launched via the [CredentialManager] framework request to retrieve credentials
* associated with the requesting entity.

View File

@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.vault.model.TotpData
@@ -44,6 +45,15 @@ fun SpecialCircumstance.toFido2AssertionRequestOrNull(): Fido2CredentialAssertio
else -> null
}
/**
* Returns [ProviderGetPasswordCredentialRequest] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toPasswordGetRequestOrNull(): ProviderGetPasswordCredentialRequest? =
when (this) {
is SpecialCircumstance.ProviderGetPasswordRequest -> this.passwordGetRequest
else -> null
}
/**
* Returns [GetCredentialsRequest] when contained in the given [SpecialCircumstance].
*/

View File

@@ -1,39 +1,43 @@
package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import kotlinx.coroutines.flow.first
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
/**
* Default implementation of [AuthenticatorBridgeRepository].
*/
class AuthenticatorBridgeRepositoryImpl(
private val authRepository: AuthRepository,
private val authDiskSource: AuthDiskSource,
private val vaultRepository: VaultRepository,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val scopedVaultSdkSource: ScopedVaultSdkSource,
) : AuthenticatorBridgeRepository {
override val authenticatorSyncSymmetricKey: ByteArray?
get() {
val doAnyAccountsHaveAuthenticatorSyncEnabled = authRepository
.userStateFlow
.value
val doAnyAccountsHaveAuthenticatorSyncEnabled = authDiskSource
.userState
?.accounts
?.any {
?.keys
?.any { userId ->
// Authenticator sync is enabled if any accounts have an authenticator
// sync key stored:
authDiskSource.getAuthenticatorSyncUnlockKey(it.userId) != null
authDiskSource.getAuthenticatorSyncUnlockKey(userId = userId) != null
}
?: false
return if (doAnyAccountsHaveAuthenticatorSyncEnabled) {
@@ -43,54 +47,43 @@ class AuthenticatorBridgeRepositoryImpl(
}
}
@Suppress("LongMethod")
override suspend fun getSharedAccounts(): SharedAccountData {
val allAccounts = authRepository.userStateFlow.value?.accounts ?: emptyList()
return allAccounts
.mapNotNull { account ->
val userId = account.userId
return authDiskSource
.userState
?.accounts
.orEmpty()
.mapNotNull { (userId, account) ->
// Grab the user's authenticator sync unlock key. If it is null,
// the user has not enabled authenticator sync.
// the user has not enabled authenticator sync and we skip the account.
val decryptedUserKey = authDiskSource.getAuthenticatorSyncUnlockKey(userId)
?: return@mapNotNull null
// Wait for any unlocking actions to finish:
vaultRepository.vaultUnlockDataStateFlow.first {
it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING
}
// Unlock vault if necessary:
val isVaultAlreadyUnlocked = vaultRepository.isVaultUnlocked(userId = userId)
if (!isVaultAlreadyUnlocked) {
val unlockResult = vaultRepository
.unlockVaultWithDecryptedUserKey(
val vaultUnlockResult = unlockClient(
userId = userId,
account = account,
decryptedUserKey = decryptedUserKey,
)
when (vaultUnlockResult) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
// Not being able to unlock the user's vault with the
// decrypted unlock key is an unexpected case, but if it does
// happen we omit the account from list of shared accounts
// and remove that user's authenticator sync unlock key.
// This gives the user a way to potentially re-enable syncing
// (going to Account Security and re-enabling the toggle)
authDiskSource.storeAuthenticatorSyncUnlockKey(
userId = userId,
decryptedUserKey = decryptedUserKey,
authenticatorSyncUnlockKey = null,
)
when (unlockResult) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
// Not being able to unlock the user's vault with the
// decrypted unlock key is an unexpected case, but if it does
// happen we omit the account from list of shared accounts
// and remove that user's authenticator sync unlock key.
// This gives the user a way to potentially re-enable syncing
// (going to Account Security and re-enabling the toggle)
authDiskSource.storeAuthenticatorSyncUnlockKey(
userId = userId,
authenticatorSyncUnlockKey = null,
)
return@mapNotNull null
}
// Proceed
VaultUnlockResult.Success -> Unit
// Destroy our stand-alone instance of the vault.
scopedVaultSdkSource.clearCrypto(userId = userId)
return@mapNotNull null
}
// Proceed
VaultUnlockResult.Success -> Unit
}
// Vault is unlocked, query vault disk source for totp logins:
@@ -99,38 +92,73 @@ class AuthenticatorBridgeRepositoryImpl(
// Filter out any deleted ciphers.
.filter { it.deletedDate == null }
.mapNotNull {
val decryptedCipher = vaultSdkSource
.decryptCipher(
userId = userId,
cipher = it.toEncryptedSdkCipher(),
)
scopedVaultSdkSource
.decryptCipher(userId = userId, cipher = it.toEncryptedSdkCipher())
.getOrNull()
val rawTotp = decryptedCipher?.login?.totp
val cipherName = decryptedCipher?.name
val username = decryptedCipher?.login?.username
rawTotp.sanitizeTotpUri(cipherName, username)
?.let { decryptedCipher ->
val rawTotp = decryptedCipher.login?.totp
val cipherName = decryptedCipher.name
val username = decryptedCipher.login?.username
rawTotp.sanitizeTotpUri(issuer = cipherName, username = username)
}
}
// Lock the user's vault if we unlocked it for this operation:
if (!isVaultAlreadyUnlocked) {
vaultRepository.lockVault(
userId = userId,
isUserInitiated = false,
)
}
// Lock and destroy our stand-alone instance of the vault:
scopedVaultSdkSource.clearCrypto(userId = userId)
SharedAccountData.Account(
userId = account.userId,
name = account.name,
email = account.email,
environmentLabel = account.environment.label,
userId = userId,
name = account.profile.name,
email = account.profile.email,
environmentLabel = account
.settings
.environmentUrlData
.toEnvironmentUrlsOrDefault()
.label,
totpUris = totpUris,
)
}
.let {
SharedAccountData(it)
.let(::SharedAccountData)
}
private suspend fun unlockClient(
userId: String,
account: AccountJson,
decryptedUserKey: String,
): VaultUnlockResult {
val privateKey = authDiskSource
.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(MissingPropertyException("Private key"))
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
userId = userId,
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,
privateKey = privateKey,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
signingKey = null,
securityState = null,
),
)
.flatMap { result ->
// Initialize the SDK for organizations if necessary
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
if (organizationKeys != null && result is InitializeCryptoResult.Success) {
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
} else {
result.asSuccess()
}
}
.fold(
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it.toVaultUnlockResult() },
)
}
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.bitwarden.core.data.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.BuildConfig
@@ -8,7 +9,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
@@ -41,7 +41,7 @@ class DebugMenuRepositoryImpl(
override fun resetFeatureFlagOverrides() {
val currentServerConfig = serverConfigRepository.serverConfigStateFlow.value
FlagKey.activeFlags.forEach { flagKey ->
FlagKey.activePasswordManagerFlags.forEach { flagKey ->
updateFeatureFlag(
flagKey,
currentServerConfig.getFlagValueOrDefault(flagKey),

View File

@@ -35,6 +35,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import java.security.GeneralSecurityException
import java.time.Instant
import javax.crypto.Cipher
@@ -501,9 +503,14 @@ class SettingsRepositoryImpl(
.onSuccess { biometricsKey ->
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1),
biometricsKey = try {
cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
Timber.w(e, "setupBiometricsKey failed encrypt the biometric key")
return BiometricsKeyResult.Error(error = e)
},
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}

View File

@@ -4,7 +4,6 @@ import android.view.autofill.AutofillManager
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
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.platform.datasource.disk.EnvironmentDiskSource
@@ -21,8 +20,8 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -39,17 +38,13 @@ object PlatformRepositoryModule {
@Provides
@Singleton
fun providesAuthenticatorBridgeRepository(
authRepository: AuthRepository,
authDiskSource: AuthDiskSource,
vaultRepository: VaultRepository,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
scopedVaultSdkSource: ScopedVaultSdkSource,
): AuthenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl(
authRepository = authRepository,
authDiskSource = authDiskSource,
vaultRepository = vaultRepository,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
scopedVaultSdkSource = scopedVaultSdkSource,
)
@Provides

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
/**
@@ -10,12 +10,12 @@ import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequenc
*/
val ClearClipboardFrequency.displayLabel: Text
get() = when (this) {
ClearClipboardFrequency.NEVER -> R.string.never
ClearClipboardFrequency.TEN_SECONDS -> R.string.ten_seconds
ClearClipboardFrequency.TWENTY_SECONDS -> R.string.twenty_seconds
ClearClipboardFrequency.THIRTY_SECONDS -> R.string.thirty_seconds
ClearClipboardFrequency.ONE_MINUTE -> R.string.one_minute
ClearClipboardFrequency.TWO_MINUTES -> R.string.two_minutes
ClearClipboardFrequency.FIVE_MINUTES -> R.string.five_minutes
ClearClipboardFrequency.NEVER -> BitwardenString.never
ClearClipboardFrequency.TEN_SECONDS -> BitwardenString.ten_seconds
ClearClipboardFrequency.TWENTY_SECONDS -> BitwardenString.twenty_seconds
ClearClipboardFrequency.THIRTY_SECONDS -> BitwardenString.thirty_seconds
ClearClipboardFrequency.ONE_MINUTE -> BitwardenString.one_minute
ClearClipboardFrequency.TWO_MINUTES -> BitwardenString.two_minutes
ClearClipboardFrequency.FIVE_MINUTES -> BitwardenString.five_minutes
}
.asText()

View File

@@ -21,6 +21,11 @@ val isDevBuild: Boolean
val versionData: String
get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
/**
* A string that represents a displayable SDK version.
*/
val sdkData: String get() = BuildConfig.SDK_VERSION
/**
* A string that represents device data.
*/

View File

@@ -10,8 +10,8 @@ import android.service.quicksettings.TileService
import androidx.annotation.Keep
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.AccessibilityActivity
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.autofill.accessibility.util.isAccessibilityServiceEnabled
@@ -62,8 +62,8 @@ class BitwardenAutofillTileService : TileService() {
private fun getAccessibilityServiceRequiredDialog(): Dialog =
AlertDialog.Builder(this)
.setMessage(R.string.autofill_tile_accessibility_required)
.setMessage(BitwardenString.autofill_tile_accessibility_required)
.setCancelable(true)
.setPositiveButton(R.string.okay) { dialog, _ -> dialog.cancel() }
.setPositiveButton(BitwardenString.okay) { dialog, _ -> dialog.cancel() }
.create()
}

View File

@@ -1,24 +1,22 @@
package com.x8bit.bitwarden.data.tiles
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.TileService
import androidx.annotation.Keep
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import com.x8bit.bitwarden.MainActivity
/**
* A service for handling the Password Generator quick settings tile.
*/
@AndroidEntryPoint
@Keep
@OmitFromCoverage
class BitwardenGeneratorTileService : TileService() {
@Inject
lateinit var intentManager: IntentManager
override fun onClick() {
if (isLocked) {
@@ -29,13 +27,22 @@ class BitwardenGeneratorTileService : TileService() {
}
private fun launchGenerator() {
val intent = intentManager.createTileIntent("bitwarden://password_generator")
val intent = Intent(applicationContext, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData("bitwarden://password_generator".toUri())
if (!isBuildVersionAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
} else {
startActivityAndCollapse(intentManager.createTilePendingIntent(0, intent))
startActivityAndCollapse(
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE,
),
)
}
}
}

View File

@@ -1,24 +1,22 @@
package com.x8bit.bitwarden.data.tiles
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.TileService
import androidx.annotation.Keep
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import com.x8bit.bitwarden.MainActivity
/**
* A service for handling the My Vault quick settings tile.
*/
@AndroidEntryPoint
@Keep
@OmitFromCoverage
class BitwardenVaultTileService : TileService() {
@Inject
lateinit var intentManager: IntentManager
override fun onClick() {
if (isLocked) {
@@ -29,13 +27,22 @@ class BitwardenVaultTileService : TileService() {
}
private fun launchVault() {
val intent = intentManager.createTileIntent("bitwarden://my_vault")
val intent = Intent(applicationContext, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData("bitwarden://my_vault".toUri())
if (!isBuildVersionAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
} else {
startActivityAndCollapse(intentManager.createTilePendingIntent(0, intent))
startActivityAndCollapse(
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE,
),
)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
/**
* This is a non-singleton instance of the [VaultSdkSource] that is intentionally separate; this
* allows you to temporarily unlock vaults for a given user within its own scope without affecting
* the foreground behavior of the app.
*
* Users of this class must always call [ScopedVaultSdkSource.clearCrypto] when they are done using
* the unlocked vault in order to ensure that this instance of the vault is re-locked.
*/
interface ScopedVaultSdkSource : VaultSdkSource

View File

@@ -0,0 +1,32 @@
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.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
/**
* The default instance of the [ScopedVaultSdkSource]. This uses its own instance of the
* [SdkClientManagerImpl] to keep it separate from the rest of the app.
*/
@OmitFromCoverage
class ScopedVaultSdkSourceImpl(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
// We do not want to have the real NativeLibraryManager used here to avoid
// initializing the library twice.
nativeLibraryManager = object : NativeLibraryManager {
override fun loadLibrary(libraryName: String): Result<Unit> = Unit.asSuccess()
},
sdkRepoFactory = sdkRepositoryFactory,
featureFlagManager = featureFlagManager,
),
dispatcherManager = dispatcherManager,
),
) : ScopedVaultSdkSource, VaultSdkSource by vaultSdkSource

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.DateTime
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
@@ -21,8 +22,6 @@ import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Collection
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.EncryptionContext
import com.bitwarden.vault.Folder
@@ -35,6 +34,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorRes
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import java.io.File
import java.time.Instant
/**
* Source of vault information and functionality from the Bitwarden SDK.
@@ -204,30 +204,6 @@ interface VaultSdkSource {
cipher: Cipher,
): Result<CipherView>
/**
* Decrypts a list of [Cipher]s for the user with the given [userId], returning a list of
* [CipherListView] wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun decryptCipherListCollection(
userId: String,
cipherList: List<Cipher>,
): Result<List<CipherListView>>
/**
* Decrypts a list of [Cipher]s for the user with the given [userId], returning a list of
* [CipherView] wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun decryptCipherList(
userId: String,
cipherList: List<Cipher>,
): Result<List<CipherView>>
/**
* Decrypts a list of [Cipher]s for the user with the given [userId].
*
@@ -397,12 +373,12 @@ interface VaultSdkSource {
): Result<List<PasswordHistoryView>>
/**
* Generate a verification code and the period using the totp code.
* Generate a verification code for the given [cipherListView] and [time].
*/
suspend fun generateTotp(
suspend fun generateTotpForCipherListView(
userId: String,
totp: String,
time: DateTime,
cipherListView: CipherListView,
time: Instant?,
): Result<TotpResponse>
/**

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.DateTime
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DeriveKeyConnectorRequest
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
@@ -23,8 +24,6 @@ import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Collection
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.EncryptionContext
import com.bitwarden.vault.Folder
@@ -48,6 +47,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.time.Instant
/**
* Primary implementation of [VaultSdkSource] that serves as a convenience wrapper around a
@@ -97,6 +97,7 @@ class VaultSdkSourceImpl(
exception.message == "Wrong password" -> {
DeriveKeyConnectorResult.WrongPasswordError
}
else -> DeriveKeyConnectorResult.Error(exception)
}
}
@@ -290,28 +291,6 @@ class VaultSdkSourceImpl(
.decrypt(cipher = cipher)
}
override suspend fun decryptCipherListCollection(
userId: String,
cipherList: List<Cipher>,
): Result<List<CipherListView>> =
runCatchingWithLogs {
getClient(userId = userId)
.vault()
.ciphers()
.decryptList(ciphers = cipherList)
}
override suspend fun decryptCipherList(
userId: String,
cipherList: List<Cipher>,
): Result<List<CipherView>> =
runCatchingWithLogs {
val ciphers = getClient(userId = userId).vault().ciphers()
withContext(context = dispatcherManager.default) {
cipherList.map { async { ciphers.decrypt(cipher = it) } }.awaitAll()
}
}
override suspend fun decryptCipherListWithFailures(
userId: String,
cipherList: List<Cipher>,
@@ -438,15 +417,15 @@ class VaultSdkSourceImpl(
.decryptList(list = passwordHistoryList)
}
override suspend fun generateTotp(
override suspend fun generateTotpForCipherListView(
userId: String,
totp: String,
time: DateTime,
cipherListView: CipherListView,
time: Instant?,
): Result<TotpResponse> = runCatchingWithLogs {
getClient(userId = userId)
.vault()
.generateTotp(
key = totp,
.generateTotpCipherView(
view = cipherListView,
time = time,
)
}

View File

@@ -3,7 +3,11 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk.di
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl
@@ -32,15 +36,27 @@ object VaultSdkModule {
dispatcherManager = dispatcherManager,
)
@Provides
fun providesScopedVaultSdkSource(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
): ScopedVaultSdkSource =
ScopedVaultSdkSourceImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
sdkRepositoryFactory = sdkRepositoryFactory,
)
@Provides
@Singleton
fun providesFido2CredentialStore(
vaultSdkSource: VaultSdkSource,
authRepository: AuthRepository,
vaultSdkSource: VaultSdkSource,
vaultRepository: VaultRepository,
): Fido2CredentialStore = Fido2CredentialStoreImpl(
vaultSdkSource = vaultSdkSource,
authRepository = authRepository,
vaultSdkSource = vaultSdkSource,
vaultRepository = vaultRepository,
)
}

View File

@@ -1,24 +1,27 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.EncryptionContext
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
import timber.log.Timber
/**
* Primary implementation of [Fido2CredentialStore].
*/
@OmitFromCoverage
class Fido2CredentialStoreImpl(
private val vaultSdkSource: VaultSdkSource,
private val authRepository: AuthRepository,
private val vaultSdkSource: VaultSdkSource,
private val vaultRepository: VaultRepository,
) : Fido2CredentialStore {
@@ -32,17 +35,13 @@ class Fido2CredentialStoreImpl(
?.let { throw it }
?: throw IllegalStateException("Sync failed.")
}
val activeCipherIds = vaultRepository.ciphersStateFlow.value.data
?.filter { it.isActiveWithFido2Credentials }
?.map { it.id }
?: emptyList()
return vaultRepository.ciphersListViewStateFlow.value.data
?.filter { clv ->
activeCipherIds
.contains(clv.id)
}
?: emptyList()
return vaultRepository
.decryptCipherListResultStateFlow
.value
.data
?.successes
.orEmpty()
.filter { it.isActiveWithFido2Credentials }
}
/**
@@ -53,8 +52,6 @@ class Fido2CredentialStoreImpl(
* @param ripId Relying Party ID to find.
*/
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> {
val userId = getActiveUserIdOrThrow()
val syncResult = vaultRepository.syncForResult()
if (syncResult is SyncVaultDataResult.Error) {
syncResult.throwable
@@ -62,30 +59,25 @@ class Fido2CredentialStoreImpl(
?: throw IllegalStateException("Sync failed.")
}
val ciphersWithFido2Credentials = vaultRepository.ciphersStateFlow.value.data
?.filter { it.isActiveWithFido2Credentials }
return vaultRepository
.decryptCipherListResultStateFlow
.value
.data
?.successes
.orEmpty()
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
userId = userId,
cipherViews = ciphersWithFido2Credentials.toTypedArray(),
.filter { it.isActiveWithFido2Credentials }
.filterMatchingCredentials(
credentialIds = ids,
relyingPartyId = ripId,
)
.map { decryptedFido2CredentialViews ->
decryptedFido2CredentialViews.filterMatchingCredentials(
ids,
ripId,
)
.mapNotNull { cipherListView ->
cipherListView.id
?.let { cipherId ->
vaultRepository
.getCipher(cipherId = cipherId)
.toCipherViewOrNull()
}
}
.map { matchingFido2Credentials ->
ciphersWithFido2Credentials.filter { cipherView ->
matchingFido2Credentials.any { it.cipherId == cipherView.id }
}
}
.fold(
onSuccess = { it },
onFailure = { throw it },
)
}
/**
@@ -94,7 +86,7 @@ class Fido2CredentialStoreImpl(
override suspend fun saveCredential(cred: EncryptionContext) {
vaultSdkSource
.decryptCipher(
userId = cred.encryptedFor,
userId = authRepository.activeUserId ?: throw NoActiveUserException(),
cipher = cred.cipher,
)
.map { decryptedCipherView ->
@@ -103,24 +95,51 @@ class Fido2CredentialStoreImpl(
?: vaultRepository.createCipher(decryptedCipherView)
}
.onFailure { throw it }
}
private fun getActiveUserIdOrThrow() = authRepository.userStateFlow.value?.activeUserId
?: throw IllegalStateException("Active user is required.")
}
/**
* Return a filtered list containing elements that match the given [relyingPartyId] and a
* credential ID contained in [credentialIds].
*/
private fun List<Fido2CredentialAutofillView>.filterMatchingCredentials(
private fun List<CipherListView>.filterMatchingCredentials(
credentialIds: List<ByteArray>?,
relyingPartyId: String,
): List<Fido2CredentialAutofillView> {
): List<CipherListView> {
val skipCredentialIdFiltering = credentialIds.isNullOrEmpty()
return filter { fido2CredentialView ->
fido2CredentialView.rpId == relyingPartyId &&
(skipCredentialIdFiltering ||
credentialIds?.contains(fido2CredentialView.credentialId) == true)
return filter { cipherListView ->
val hasMatchingRpId = cipherListView.login
?.fido2Credentials
.orEmpty()
.any { it.rpId == relyingPartyId }
val fido2CredentialIds = cipherListView.login
?.fido2Credentials
.orEmpty()
.map { it.credentialId.toByteArray() }
val hasIntersectingCredentials = credentialIds
?.intersect(fido2CredentialIds)
.orEmpty()
.isNotEmpty()
hasMatchingRpId &&
(skipCredentialIdFiltering || hasIntersectingCredentials)
}
}
private fun GetCipherResult.toCipherViewOrNull(): CipherView? {
return when (this) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found for FIDO 2 credential.")
null
}
is GetCipherResult.Failure -> {
Timber.e(this.error, "Failed to decrypt cipher for FIDO 2 credential.")
null
}
is GetCipherResult.Success -> this.cipherView
}
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CipherListView
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import kotlinx.coroutines.flow.StateFlow
@@ -11,18 +11,20 @@ import kotlinx.coroutines.flow.StateFlow
interface TotpCodeManager {
/**
* Flow for getting a DataState with multiple verification code items.
* Flow for getting a DataState with multiple verification code items for the given
* [cipherListViews].
*/
fun getTotpCodesStateFlow(
fun getTotpCodesForCipherListViewsStateFlow(
userId: String,
cipherList: List<CipherView>,
cipherListViews: List<CipherListView>,
): StateFlow<DataState<List<VerificationCodeItem>>>
/**
* Flow for getting a DataState with a single verification code item.
* Flow for getting a DataState with a single verification code item for the given
* [cipherListView].
*/
fun getTotpCodeStateFlow(
userId: String,
cipher: CipherView,
cipherListView: CipherListView,
): StateFlow<DataState<VerificationCodeItem?>>
}

View File

@@ -1,10 +1,10 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.DateTime
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import kotlinx.coroutines.CoroutineScope
@@ -33,16 +33,16 @@ class TotpCodeManagerImpl(
) : TotpCodeManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableVerificationCodeStateFlowMap =
mutableMapOf<CipherView, StateFlow<DataState<VerificationCodeItem?>>>()
private val mutableCipherListViewVerificationCodeStateFlowMap =
mutableMapOf<CipherListView, StateFlow<DataState<VerificationCodeItem?>>>()
override fun getTotpCodesStateFlow(
override fun getTotpCodesForCipherListViewsStateFlow(
userId: String,
cipherList: List<CipherView>,
cipherListViews: List<CipherListView>,
): StateFlow<DataState<List<VerificationCodeItem>>> {
// Generate state flows
val stateFlows = cipherList.map { cipherView ->
getTotpCodeStateFlowInternal(userId, cipherView)
val stateFlows = cipherListViews.map { cipherListView ->
getTotpCodeStateFlowInternal(userId, cipherListView)
}
return combine(stateFlows) { results ->
when {
@@ -66,62 +66,55 @@ class TotpCodeManagerImpl(
override fun getTotpCodeStateFlow(
userId: String,
cipher: CipherView,
cipherListView: CipherListView,
): StateFlow<DataState<VerificationCodeItem?>> =
getTotpCodeStateFlowInternal(
userId = userId,
cipher = cipher,
cipherListView = cipherListView,
)
@Suppress("LongMethod")
private fun getTotpCodeStateFlowInternal(
userId: String,
cipher: CipherView?,
cipherListView: CipherListView?,
): StateFlow<DataState<VerificationCodeItem?>> {
val cipherId = cipher?.id ?: return MutableStateFlow(DataState.Loaded(null))
val cipherId = cipherListView?.id ?: return MutableStateFlow(DataState.Loaded(null))
cipherListView.login?.totp ?: return MutableStateFlow(DataState.Loaded(null))
return mutableVerificationCodeStateFlowMap.getOrPut(cipher) {
return mutableCipherListViewVerificationCodeStateFlowMap.getOrPut(cipherListView) {
// Define a per-item scope so that we can clear the Flow from the scope when it is
// no longer needed.
val itemScope = CoroutineScope(dispatcherManager.unconfined)
flow<DataState<VerificationCodeItem?>> {
val totpCode = cipher
.login
?.totp
?: run {
emit(DataState.Loaded(null))
return@flow
}
var item: VerificationCodeItem? = null
while (currentCoroutineContext().isActive) {
val time = (clock.millis() / ONE_SECOND_MILLISECOND).toInt()
val dateTime = DateTime.now()
val dateTime = clock.instant()
val time = dateTime.epochSecond.toInt()
if (item == null || item.isExpired(clock = clock)) {
vaultSdkSource
.generateTotp(
totp = totpCode,
.generateTotpForCipherListView(
cipherListView = cipherListView,
userId = userId,
time = dateTime,
)
.onSuccess { response ->
item = VerificationCodeItem(
code = response.code,
totpCode = totpCode,
periodSeconds = response.period.toInt(),
timeLeftSeconds = response.period.toInt() -
time % response.period.toInt(),
issueTime = clock.millis(),
uriLoginViewList = cipher.login?.uris,
uriLoginViewList = cipherListView.login?.uris,
id = cipherId,
name = cipher.name,
username = cipher.login?.username,
hasPasswordReprompt = when (cipher.reprompt) {
name = cipherListView.name,
username = cipherListView.login?.username,
hasPasswordReprompt = when (cipherListView.reprompt) {
CipherRepromptType.PASSWORD -> true
CipherRepromptType.NONE -> false
},
orgUsesTotp = cipher.organizationUseTotp,
orgUsesTotp = cipherListView.organizationUseTotp,
)
}
.onFailure {
@@ -129,12 +122,9 @@ class TotpCodeManagerImpl(
return@flow
}
} else {
item?.let {
item = it.copy(
timeLeftSeconds = it.periodSeconds -
(time % it.periodSeconds),
)
}
item = item.copy(
timeLeftSeconds = item.periodSeconds - (time % item.periodSeconds),
)
}
item?.let {
@@ -144,7 +134,7 @@ class TotpCodeManagerImpl(
}
}
.onCompletion {
mutableVerificationCodeStateFlowMap.remove(cipher)
mutableCipherListViewVerificationCodeStateFlowMap.remove(cipherListView)
itemScope.cancel()
}
.stateIn(

View File

@@ -183,6 +183,7 @@ class VaultLockManagerImpl(
method = initUserCryptoMethod,
userId = userId,
signingKey = null,
securityState = null,
),
)
.flatMap { result ->

View File

@@ -6,7 +6,6 @@ import com.bitwarden.vault.LoginUriView
* Models the items returned by the TotpCodeManager.
*
* @property code The verification code for the item.
* @property totpCode The totp code for the item.
* @property periodSeconds The time span where the code is valid in seconds.
* @property timeLeftSeconds The seconds remaining until a new code is required.
* @property issueTime The time the verification code was issued.
@@ -19,7 +18,6 @@ import com.bitwarden.vault.LoginUriView
*/
data class VerificationCodeItem(
val code: String,
val totpCode: String,
val periodSeconds: Int,
val timeLeftSeconds: Int,
val issueTime: Long,

View File

@@ -1,6 +1,7 @@
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
@@ -9,15 +10,15 @@ 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.CollectionView
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.VaultLockManager
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.DecryptFido2CredentialAutofillViewResult
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
@@ -59,20 +60,13 @@ interface VaultRepository : CipherManager, VaultLockManager {
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents all ciphers for the active user.
* 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 ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
/**
* Flow that represents all ciphers 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 ciphersListViewStateFlow: StateFlow<DataState<List<CipherListView>>>
val decryptCipherListResultStateFlow: StateFlow<DataState<DecryptCipherListResult>>
/**
* Flow that represents all collections for the active user.
@@ -163,13 +157,6 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
fun getAuthCodesFlow(): StateFlow<DataState<List<VerificationCodeItem>>>
/**
* Get the decrypted list of fido credentials for the current ciphers and user id.
*/
suspend fun getDecryptedFido2CredentialAutofillViews(
cipherViewList: List<CipherView>,
): DecryptFido2CredentialAutofillViewResult
/**
* Silently discovers FIDO 2 credentials for a given [userId] and [relyingPartyId].
*/
@@ -238,7 +225,7 @@ interface VaultRepository : CipherManager, VaultLockManager {
/**
* Attempt to get the verification code and the period.
*/
suspend fun generateTotp(totpCode: String, time: DateTime): GenerateTotpResult
suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult
/**
* Attempt to delete a send.
@@ -262,6 +249,18 @@ interface VaultRepository : CipherManager, VaultLockManager {
/**
* Attempt to get the user's vault data for export.
*
* @param format The export format to use.
* @param restrictedTypes A list of restricted types to export.
*/
suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult
suspend fun exportVaultDataToString(
format: ExportFormat,
restrictedTypes: List<CipherType>,
): ExportVaultDataResult
/**
* 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.
*/
fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>>
}

View File

@@ -1,6 +1,7 @@
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.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
@@ -31,9 +32,10 @@ import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CollectionView
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
@@ -41,6 +43,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
@@ -61,10 +64,10 @@ import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
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.DecryptFido2CredentialAutofillViewResult
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
@@ -118,6 +121,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber
import java.security.GeneralSecurityException
import java.time.Clock
import java.time.temporal.ChronoUnit
@@ -167,11 +171,8 @@ class VaultRepositoryImpl(
private val mutableSendDataStateFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val mutableCiphersStateFlow =
MutableStateFlow<DataState<List<CipherView>>>(DataState.Loading)
private val mutableCiphersListViewStateFlow =
MutableStateFlow<DataState<List<CipherListView>>>(DataState.Loading)
private val mutableDecryptCipherListResultFlow =
MutableStateFlow<DataState<DecryptCipherListResult>>(DataState.Loading)
private val mutableFoldersStateFlow =
MutableStateFlow<DataState<List<FolderView>>>(DataState.Loading)
@@ -186,7 +187,7 @@ class VaultRepositoryImpl(
override val vaultDataStateFlow: StateFlow<DataState<VaultData>> =
combine(
ciphersStateFlow,
decryptCipherListResultStateFlow,
foldersStateFlow,
collectionsStateFlow,
sendDataStateFlow,
@@ -198,10 +199,9 @@ class VaultRepositoryImpl(
sendsDataState,
) { ciphersData, foldersData, collectionsData, sendsData ->
VaultData(
cipherViewList = ciphersData,
fido2CredentialAutofillViewList = null,
folderViewList = foldersData,
decryptCipherListResult = ciphersData,
collectionViewList = collectionsData,
folderViewList = foldersData,
sendViewList = sendsData.sendViewList,
)
}
@@ -215,11 +215,8 @@ class VaultRepositoryImpl(
override val totpCodeFlow: Flow<TotpCodeResult>
get() = mutableTotpCodeResultFlow.asSharedFlow()
override val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableCiphersStateFlow.asStateFlow()
override val ciphersListViewStateFlow: StateFlow<DataState<List<CipherListView>>>
get() = mutableCiphersListViewStateFlow.asStateFlow()
override val decryptCipherListResultStateFlow: StateFlow<DataState<DecryptCipherListResult>>
get() = mutableDecryptCipherListResultFlow.asStateFlow()
override val domainsStateFlow: StateFlow<DataState<DomainsData>>
get() = mutableDomainsStateFlow.asStateFlow()
@@ -258,16 +255,7 @@ class VaultRepositoryImpl(
.launchIn(unconfinedScope)
// Setup ciphers MutableStateFlow
mutableCiphersStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCiphers(activeUserId)
}
.launchIn(unconfinedScope)
mutableCiphersListViewStateFlow
mutableDecryptCipherListResultFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
@@ -314,7 +302,7 @@ class VaultRepositoryImpl(
pushManager
.fullSyncFlow
.onEach { syncIfNecessary() }
.onEach { sync(forced = false) }
.launchIn(unconfinedScope)
pushManager
@@ -354,7 +342,7 @@ class VaultRepositoryImpl(
}
private fun clearUnlockedData() {
mutableCiphersStateFlow.update { DataState.Loading }
mutableDecryptCipherListResultFlow.update { DataState.Loading }
mutableFoldersStateFlow.update { DataState.Loading }
mutableCollectionsStateFlow.update { DataState.Loading }
mutableSendDataStateFlow.update { DataState.Loading }
@@ -369,7 +357,7 @@ class VaultRepositoryImpl(
override fun sync(forced: Boolean) {
val userId = activeUserId ?: return
if (!syncJob.isCompleted) return
mutableCiphersStateFlow.updateToPendingOrLoading()
mutableDecryptCipherListResultFlow.updateToPendingOrLoading()
mutableDomainsStateFlow.updateToPendingOrLoading()
mutableFoldersStateFlow.updateToPendingOrLoading()
mutableCollectionsStateFlow.updateToPendingOrLoading()
@@ -406,11 +394,33 @@ class VaultRepositoryImpl(
}
override fun getVaultItemStateFlow(itemId: String): StateFlow<DataState<CipherView?>> =
vaultDataStateFlow
.map { dataState ->
dataState.map { vaultData ->
val getCipherResult = vaultData
.decryptCipherListResult
.successes
.find { it.id == itemId }
.let { getCipher(itemId) }
when (getCipherResult) {
is GetCipherResult.Success -> getCipherResult.cipherView
else -> null
}
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Lazily,
initialValue = DataState.Loading,
)
override fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>> =
vaultDataStateFlow
.map { dataState ->
dataState.map { vaultData ->
vaultData
.cipherViewList
.decryptCipherListResult
.successes
.find { it.id == itemId }
}
}
@@ -457,9 +467,10 @@ class VaultRepositoryImpl(
.map { dataState ->
dataState.map { vaultData ->
vaultData
.cipherViewList
.decryptCipherListResult
.successes
.filter {
it.type == CipherType.LOGIN &&
it.type is CipherListViewType.Login &&
!it.login?.totp.isNullOrBlank() &&
it.deletedDate == null
}
@@ -469,9 +480,9 @@ class VaultRepositoryImpl(
.flatMapLatest { cipherDataState ->
val cipherList = cipherDataState.data ?: emptyList()
totpCodeManager
.getTotpCodesStateFlow(
.getTotpCodesForCipherListViewsStateFlow(
userId = userId,
cipherList = cipherList,
cipherListViews = cipherList,
)
.map { verificationCodeDataStates ->
combineDataStates(
@@ -496,13 +507,13 @@ class VaultRepositoryImpl(
val userId = activeUserId ?: return MutableStateFlow(
DataState.Error(IllegalStateException("No active user"), null),
)
return getVaultItemStateFlow(cipherId)
return getVaultListItemStateFlow(cipherId)
.flatMapLatest { cipherDataState ->
cipherDataState
.data
?.let {
totpCodeManager
.getTotpCodeStateFlow(userId = userId, cipher = it)
.getTotpCodeStateFlow(userId = userId, cipherListView = it)
.map { totpCodeDataState ->
combineDataStates(totpCodeDataState, cipherDataState) { _, _ ->
// We are only combining the DataStates to know the overall
@@ -520,22 +531,6 @@ class VaultRepositoryImpl(
)
}
override suspend fun getDecryptedFido2CredentialAutofillViews(
cipherViewList: List<CipherView>,
): DecryptFido2CredentialAutofillViewResult {
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
userId = activeUserId ?: return DecryptFido2CredentialAutofillViewResult.Error(
error = NoActiveUserException(),
),
cipherViews = cipherViewList.toTypedArray(),
)
.fold(
onFailure = { DecryptFido2CredentialAutofillViewResult.Error(error = it) },
onSuccess = { DecryptFido2CredentialAutofillViewResult.Success(it) },
)
}
override suspend fun silentlyDiscoverCredentials(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
@@ -578,6 +573,7 @@ class VaultRepositoryImpl(
.doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1))
.decodeToString()
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockVaultWithBiometrics failed when decrypting biometrics key")
return VaultUnlockResult.BiometricDecodingError(error = e)
}
}
@@ -591,6 +587,7 @@ class VaultRepositoryImpl(
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockVaultWithBiometrics failed to migrate the user to IV encryption")
return VaultUnlockResult.BiometricDecodingError(error = e)
}
} else {
@@ -808,15 +805,24 @@ class VaultRepositoryImpl(
}
override suspend fun generateTotp(
totpCode: String,
cipherId: String,
time: DateTime,
): GenerateTotpResult {
val userId = activeUserId
?: return GenerateTotpResult.Error(error = NoActiveUserException())
return vaultSdkSource.generateTotp(
val cipherListView = decryptCipherListResultStateFlow
.value
.data
?.successes
?.find { it.id == cipherId }
?: return GenerateTotpResult.Error(
error = IllegalArgumentException(cipherId),
)
return vaultSdkSource.generateTotpForCipherListView(
time = time,
userId = userId,
totp = totpCode,
cipherListView = cipherListView,
)
.fold(
onSuccess = {
@@ -931,7 +937,10 @@ class VaultRepositoryImpl(
}
}
override suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult {
override suspend fun exportVaultDataToString(
format: ExportFormat,
restrictedTypes: List<CipherType>,
): ExportVaultDataResult {
val userId = activeUserId
?: return ExportVaultDataResult.Error(error = NoActiveUserException())
val folders = vaultDiskSource
@@ -945,7 +954,11 @@ class VaultRepositoryImpl(
.firstOrNull()
.orEmpty()
.map { it.toEncryptedSdkCipher() }
.filter { it.collectionIds.isEmpty() && it.deletedDate == null }
.filter {
it.collectionIds.isEmpty() &&
it.deletedDate == null &&
!restrictedTypes.contains(it.type)
}
return vaultSdkSource
.exportVaultDataToString(
@@ -1063,47 +1076,35 @@ class VaultRepositoryImpl(
)
}
private fun observeVaultDiskCiphers(
userId: String,
): Flow<DataState<List<CipherView>>> =
vaultDiskSource
.getCiphersFlow(userId = userId)
.onStart { mutableCiphersStateFlow.updateToPendingOrLoading() }
.map {
waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherList(
userId = userId,
cipherList = it.toEncryptedSdkCipherList(),
)
.fold(
onSuccess = { ciphers -> DataState.Loaded(ciphers.sortAlphabetically()) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableCiphersStateFlow.value = it }
private fun observeVaultDiskCiphersToCipherListView(
userId: String,
): Flow<DataState<List<CipherListView>>> =
): Flow<DataState<DecryptCipherListResult>> =
vaultDiskSource
.getCiphersFlow(userId = userId)
.onStart { mutableCiphersListViewStateFlow.updateToPendingOrLoading() }
.onStart { mutableDecryptCipherListResultFlow.updateToPendingOrLoading() }
.map {
waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherListCollection(
.decryptCipherListWithFailures(
userId = userId,
cipherList = it.toEncryptedSdkCipherList(),
)
.fold(
onSuccess = { ciphers -> DataState.Loaded(ciphers.sortAlphabetically()) },
onSuccess = { result ->
// TODO (PM-18210): Display decryption result failures
DataState.Loaded(
result.copy(successes = result.successes.sortAlphabetically()),
)
},
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableCiphersListViewStateFlow.value = it }
.map {
it
.takeUnless { settingsDiskSource.getLastSyncTime(userId = userId) == null }
?: DataState.Loading
}
.onEach { mutableDecryptCipherListResultFlow.value = it }
private fun observeVaultDiskDomains(
userId: String,
@@ -1187,7 +1188,7 @@ class VaultRepositoryImpl(
.onEach { mutableSendDataStateFlow.value = it }
private fun updateVaultStateFlowsToError(throwable: Throwable) {
mutableCiphersStateFlow.update { currentState ->
mutableDecryptCipherListResultFlow.update { currentState ->
throwable.toNetworkOrErrorState(
data = currentState.data,
)
@@ -1255,8 +1256,8 @@ class VaultRepositoryImpl(
val revisionDate = syncCipherUpsertData.revisionDate
val isUpdate = syncCipherUpsertData.isUpdate
val localCipher = ciphersStateFlow
.mapNotNull { it.data }
val localCipher = decryptCipherListResultStateFlow
.mapNotNull { it.data?.successes }
.first()
.find { it.id == cipherId }

View File

@@ -1,24 +1,21 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.collections.CollectionView
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
/**
* Represents decrypted vault data.
*
* @param cipherViewList List of decrypted ciphers.
* @param decryptCipherListResult Contains the result of decrypting ciphers for display in a list.
* @param collectionViewList List of decrypted collections.
* @param folderViewList List of decrypted folders.
* @param sendViewList List of decrypted sends.
* @param fido2CredentialAutofillViewList List of decrypted fido 2 credentials.
*/
data class VaultData(
val cipherViewList: List<CipherView>,
val decryptCipherListResult: DecryptCipherListResult,
val collectionViewList: List<CollectionView>,
val folderViewList: List<FolderView>,
val sendViewList: List<SendView>,
val fido2CredentialAutofillViewList: List<Fido2CredentialAutofillView>? = null,
)

View File

@@ -1,9 +1,9 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.vault.Collection
import com.bitwarden.vault.CollectionView
/**
* Converts a [SyncResponseJson.Collection] object to a corresponding Bitwarden SDK [Collection]

View File

@@ -40,9 +40,9 @@ import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.model.WindowSize
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.rememberWindowSize
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers.rememberSetupAutoFillHandler
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@@ -80,17 +80,19 @@ fun SetupAutoFillScreen(
is SetupAutoFillDialogState.AutoFillFallbackDialog -> {
BitwardenBasicDialog(
title = null,
message = stringResource(id = R.string.bitwarden_autofill_go_to_settings),
message = stringResource(id = BitwardenString.bitwarden_autofill_go_to_settings),
onDismissRequest = handler.onDismissDialog,
)
}
is SetupAutoFillDialogState.TurnOnLaterDialog -> {
BitwardenTwoButtonDialog(
title = stringResource(R.string.turn_on_autofill_later),
message = stringResource(R.string.return_to_complete_this_step_anytime_in_settings),
confirmButtonText = stringResource(id = R.string.confirm),
dismissButtonText = stringResource(id = R.string.cancel),
title = stringResource(BitwardenString.turn_on_autofill_later),
message = stringResource(
id = BitwardenString.return_to_complete_this_step_anytime_in_settings,
),
confirmButtonText = stringResource(id = BitwardenString.confirm),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = handler.onConfirmTurnOnLaterClick,
onDismissClick = handler.onDismissDialog,
onDismissRequest = handler.onDismissDialog,
@@ -109,9 +111,9 @@ fun SetupAutoFillScreen(
BitwardenTopAppBar(
title = stringResource(
id = if (state.isInitialSetup) {
R.string.account_setup
BitwardenString.account_setup
} else {
R.string.turn_on_autofill
BitwardenString.turn_on_autofill
},
),
scrollBehavior = scrollBehavior,
@@ -120,7 +122,9 @@ fun SetupAutoFillScreen(
} else {
NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
navigationIconContentDescription = stringResource(
id = BitwardenString.close,
),
onNavigationIconClick = remember(viewModel) {
{
viewModel.trySendAction(SetupAutoFillAction.CloseClick)
@@ -164,7 +168,7 @@ private fun SetupAutoFillContent(
Spacer(modifier = Modifier.height(24.dp))
BitwardenSwitch(
label = stringResource(
R.string.autofill_services,
BitwardenString.autofill_services,
),
isChecked = state.autofillEnabled,
onCheckedChange = onAutofillServiceChanged,
@@ -175,7 +179,7 @@ private fun SetupAutoFillContent(
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.continue_text),
label = stringResource(id = BitwardenString.continue_text),
onClick = onContinueClick,
modifier = Modifier
.fillMaxWidth()
@@ -184,7 +188,7 @@ private fun SetupAutoFillContent(
Spacer(modifier = Modifier.height(12.dp))
if (state.isInitialSetup) {
BitwardenTextButton(
label = stringResource(R.string.turn_on_later),
label = stringResource(BitwardenString.turn_on_later),
onClick = onTurnOnLaterClick,
modifier = Modifier
.fillMaxWidth()
@@ -243,14 +247,14 @@ private fun OrderedHeaderContent() {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.turn_on_autofill),
text = stringResource(BitwardenString.turn_on_autofill),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.use_autofill_to_log_into_your_accounts),
text = stringResource(BitwardenString.use_autofill_to_log_into_your_accounts),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,

View File

@@ -29,8 +29,8 @@ import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
/**
@@ -55,7 +55,7 @@ fun SetupCompleteScreen(
topBar = {
BitwardenTopAppBar(
scrollBehavior = scrollBehavior,
title = stringResource(R.string.account_setup),
title = stringResource(BitwardenString.account_setup),
navigationIcon = null,
)
},
@@ -89,7 +89,7 @@ private fun SetupCompleteContent(
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.youre_all_set),
text = stringResource(BitwardenString.youre_all_set),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
@@ -99,7 +99,7 @@ private fun SetupCompleteContent(
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.what_bitwarden_has_to_offer),
text = stringResource(BitwardenString.what_bitwarden_has_to_offer),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
@@ -109,7 +109,7 @@ private fun SetupCompleteContent(
)
Spacer(Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(R.string.continue_text),
label = stringResource(BitwardenString.continue_text),
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()

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