Compare commits

..

4 Commits

447 changed files with 8157 additions and 17005 deletions

4
.github/CODEOWNERS vendored
View File

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

View File

@@ -15,5 +15,3 @@ contact_links:
- name: Security Issues
url: https://hackerone.com/bitwarden
about: We use HackerOne to manage security disclosures.
- name: Report mobile autofill failure
url: https://docs.google.com/forms/d/e/1FAIpQLScMopHyN7KGJs8hW562VTzbIGL4KcFnx0wJcsW0GYE1BnPiGA/viewform

View File

@@ -40,10 +40,10 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
~/.gradle/caches
@@ -53,7 +53,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
${{ github.workspace }}/build-cache
@@ -62,13 +62,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 # v1.207.0
uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0
with:
bundler-cache: true
@@ -85,7 +85,7 @@ jobs:
run: bundle exec fastlane assembleDebugApks
- name: Upload test reports on failure
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
if: failure()
with:
name: test-reports
@@ -106,7 +106,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 # v1.207.0
uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0
with:
bundler-cache: true
@@ -157,10 +157,10 @@ jobs:
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
~/.gradle/caches
@@ -170,7 +170,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
${{ github.workspace }}/build-cache
@@ -179,20 +179,11 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
$GITHUB_REPOSITORY \
$GITHUB_REF_NAME \
$GITHUB_SHA \
$GITHUB_RUN_ID \
$GITHUB_RUN_ATTEMPT
- name: Increment version
run: |
DEFAULT_VERSION_CODE=$((11000+$GITHUB_RUN_NUMBER))
@@ -253,78 +244,78 @@ jobs:
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab
if-no-files-found: error
- name: Upload beta Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab
if-no-files-found: error
- name: Upload release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk
if-no-files-found: error
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk
if-no-files-found: error
- name: Create checksum for release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk" \
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk" \
> ./com.x8bit.bitwarden.apk-sha256.txt
- name: Create checksum for beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk" \
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk" \
> ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Create checksum for release .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab" \
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab" \
> ./com.x8bit.bitwarden.aab-sha256.txt
- name: Create checksum for beta .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab" \
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab" \
> ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Create checksum for Debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk" \
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
@@ -332,7 +323,7 @@ jobs:
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
@@ -340,7 +331,7 @@ jobs:
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
@@ -348,7 +339,7 @@ jobs:
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
@@ -356,7 +347,7 @@ jobs:
- name: Upload .apk SHA file for debug
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
@@ -405,7 +396,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 # v1.207.0
uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0
with:
bundler-cache: true
@@ -442,10 +433,10 @@ jobs:
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
~/.gradle/caches
@@ -455,7 +446,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
${{ github.workspace }}/build-cache
@@ -464,20 +455,11 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
$GITHUB_REPOSITORY \
$GITHUB_REF_NAME \
$GITHUB_SHA \
$GITHUB_RUN_ID \
$GITHUB_RUN_ATTEMPT
# Start from 11000 to prevent collisions with mobile build version codes
- name: Increment version
run: |
@@ -515,38 +497,38 @@ jobs:
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk
if-no-files-found: error
- name: Create checksum for F-Droid artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk" \
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk
if-no-files-found: error
- name: Create checksum for F-Droid Beta artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk" \
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt

View File

@@ -2,7 +2,7 @@ name: Crowdin Sync
on:
workflow_dispatch:
inputs: {}
inputs: { }
schedule:
- cron: '0 0 * * 5'
@@ -28,17 +28,10 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Download translations
uses: crowdin/github-action@8dfaf9c206381653e3767e3cb5ea5f08b45f02bf # v2.5.0
uses: crowdin/github-action@2d540f18b0a416b1fbf2ee5be35841bd380fc1da # v2.3.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
with:
config: crowdin.yml

View File

@@ -23,13 +23,13 @@ jobs:
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
uses: bitwarden/gh-actions/get-keyvault-secrets@2bd1450c2cdb2a8ac886232b8589696f22794229 # v0.2.0
with:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@8dfaf9c206381653e3767e3cb5ea5f08b45f02bf # v2.5.0
uses: crowdin/github-action@2d540f18b0a416b1fbf2ee5be35841bd380fc1da # v2.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -11,7 +11,7 @@ on:
description: 'Version Number - E.g. "123456"'
required: true
type: string
artifact-run-id:
artifact_run_id:
description: 'GitHub Action Run ID containing artifacts'
required: true
type: string
@@ -22,8 +22,7 @@ on:
prerelease:
description: 'Mark as pre-release'
type: boolean
default: true
make-latest:
make_latest:
description: 'Set as the latest release'
type: boolean
branch-protection-type:
@@ -37,7 +36,6 @@ env:
ARTIFACTS_PATH: artifacts
jobs:
create-release:
name: Create GitHub Release
runs-on: ubuntu-24.04
permissions:
contents: write
@@ -45,7 +43,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 0
@@ -53,7 +51,7 @@ jobs:
id: get_release_branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
BRANCH_PROTECTION_TYPE: ${{ inputs.branch-protection-type }}
run: |
release_branch=$(gh run view $ARTIFACT_RUN_ID --json headBranch -q .headBranch)
@@ -83,7 +81,7 @@ jobs:
- name: Download artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
run: |
gh run download $ARTIFACT_RUN_ID -D $ARTIFACTS_PATH
file_count=$(find $ARTIFACTS_PATH -type f | wc -l)
@@ -95,13 +93,13 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # v2.0.9
with:
tag_name: "v${{ inputs.version-name }}"
name: "${{ inputs.version-name }} (${{ inputs.version-number }})"
tag_name: ${{ inputs.version-name }}
name: "v${{ inputs.version-name }} (${{ inputs.version-number }})"
prerelease: ${{ inputs.prerelease }}
draft: ${{ inputs.draft }}
make_latest: ${{ inputs.make-latest }}
make_latest: ${{ inputs.make_latest }}
target_commitish: ${{ steps.get_release_branch.outputs.release_branch }}
generate_release_notes: true
files: |
@@ -112,7 +110,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.create_release.outputs.id }}
RELEASE_URL: ${{ steps.create_release.outputs.url }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
run: |
# Get current release body
current_body=$(gh api /repos/${{ github.repository }}/releases/$RELEASE_ID --jq .body)

View File

@@ -10,23 +10,26 @@ on:
options:
- RC
- Hotfix
rc_prefix_date:
description: 'RC - Prefix with date. E.g. 2024.11-rc1'
type: boolean
default: true
jobs:
create-release-branch:
name: Create Release Branch
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 0
- name: Create RC Branch
if: inputs.release_type == 'RC'
env:
RC_PREFIX_DATE: "true" # replace with input if needed
RC_PREFIX_DATE: ${{ inputs.rc_prefix_date }}
run: |
if [ "$RC_PREFIX_DATE" = "true" ]; then
current_date=$(date +'%Y.%m')
@@ -42,17 +45,12 @@ jobs:
- name: Create Hotfix Branch
if: inputs.release_type == 'Hotfix'
run: |
latest_tag=$(git tag -l --sort=-creatordate | head -n 1)
latest_tag=$(git describe --tags --abbrev=0)
if [ -z "$latest_tag" ]; then
echo "::error::No tags found in the repository"
exit 1
fi
branch_name="release/hotfix-${latest_tag}"
echo "🌿 branch name: $branch_name"
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
fi
git switch -c $branch_name $latest_tag
git push origin $branch_name
echo "# :fire: Hotfix branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,60 +0,0 @@
name: Scan Protected Branches On Push
on:
workflow_dispatch:
push:
branches:
- "main"
jobs:
sast:
name: SAST scan
runs-on: ubuntu-24.04
permissions:
contents: read
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@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
sarif_file: cx_result.sarif
quality:
name: Quality scan
runs-on: ubuntu-24.04
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@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}

View File

@@ -1,7 +1,12 @@
name: Scan Pull Requests
name: Scan
on:
workflow_dispatch:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
pull_request_target:
types: [opened, synchronize]
merge_group:
@@ -28,7 +33,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
uses: checkmarx/ast-github-action@03a90e7253dadd7e2fff55f5dfbce647b39040a1 # 2.0.37
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
@@ -43,7 +48,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
uses: github/codeql-action/upload-sarif@9278e421667d5d90a2839487a482448c4ec7df4d # v3.27.2
with:
sarif_file: cx_result.sarif
@@ -63,9 +68,10 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
uses: sonarsource/sonarcloud-github-action@383f7e52eae3ab0510c3cb0e7d9d150bbaeab838 # v3.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}

View File

@@ -6,33 +6,42 @@ on:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
pull_request_target:
types: [opened, synchronize]
merge_group:
type: [checks_requested]
workflow_dispatch:
env:
_JAVA_VERSION: 17
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 17
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
test:
name: Test
runs-on: ubuntu-24.04
needs: check-run
permissions:
contents: read
issues: write
packages: read
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/wrapper-validation@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0
- name: Cache Gradle files
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
~/.gradle/caches
@@ -42,7 +51,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: |
${{ github.workspace }}/build-cache
@@ -51,15 +60,15 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 # v1.207.0
uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: "temurin"
java-version: ${{ env._JAVA_VERSION }}
java-version: ${{ env.JAVA_VERSION }}
- name: Install Fastlane
run: |
@@ -68,56 +77,19 @@ jobs:
bundle install --jobs 4 --retry 3
- name: Build and test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
run: |
bundle exec fastlane check
- name: Upload test reports
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
if: always()
with:
name: test-reports
path: |
app/build/reports/tests/
app/build/reports/kover/reportStandardDebug.xml
report:
name: Process Test Reports
needs: test
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
if: always()
steps:
- name: Download test artifacts
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
- name: Upload test reports on failure
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
continue-on-error: true
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
with:
os: linux
files: kover/reportStandardDebug.xml
fail_ci_if_error: true
- name: Comment PR if tests failed
if: steps.upload-to-codecov.outcome == 'failure'
file: app/build/reports/kover/reportStandardDebug.xml
env:
PR_NUMBER: ${{ github.event.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ACTOR: ${{ github.triggering_actor }}
run: |
echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> $GITHUB_STEP_SUMMARY
if [ ! -z "$PR_NUMBER" ]; then
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
gh pr comment --repo $GITHUB_REPOSITORY $PR_NUMBER --body "$message"
fi
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -10,16 +10,16 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1027.0)
aws-sdk-core (3.214.0)
aws-partitions (1.1003.0)
aws-sdk-core (3.212.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-s3 (1.170.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -32,7 +32,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
date (3.4.1)
date (3.4.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
@@ -59,8 +59,8 @@ GEM
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
@@ -69,7 +69,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.226.0)
fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -109,7 +109,7 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-firebase_app_distribution (0.9.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
@@ -158,11 +158,11 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.9.1)
json (2.8.1)
jwt (2.9.3)
base64
mini_magick (4.13.2)
@@ -182,8 +182,8 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.0)
rouge (3.28.0)
rexml (3.3.9)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.5)
@@ -216,8 +216,8 @@ GEM
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.0)
rouge (~> 3.28.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)

View File

@@ -132,11 +132,6 @@ The following is a list of all third-party dependencies included as part of the
- https://github.com/firebase/firebase-android-sdk
- Purpose: SDK for crash and non-fatal error reporting. (**NOTE:** This dependency is not included in builds distributed via F-Droid.)
- License: Apache 2.0
- **Google Play Reviews**
- https://developer.android.com/reference/com/google/android/play/core/release-notes
- Purpose: On standard builds provide an interface to add a review for the password manager application in Google Play.
- License: Apache 2.0
- **Glide**
- https://github.com/bumptech/glide

View File

@@ -1,9 +1,6 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.utils.cxx.io.removeExtensionIfPresent
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
import com.google.gms.googleservices.GoogleServicesTask
import dagger.hilt.android.plugin.util.capitalize
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
@@ -35,16 +32,6 @@ val userProperties = Properties().apply {
}
}
/**
* Loads CI-specific build properties that are not checked into source control.
*/
val ciProperties = Properties().apply {
val ciPropsFile = File(rootDir, "ci.properties")
if (ciPropsFile.exists()) {
FileInputStream(ciPropsFile).use { load(it) }
}
}
android {
namespace = "com.x8bit.bitwarden"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -64,12 +51,6 @@ android {
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField(
type = "String",
name = "CI_INFO",
value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}"
)
}
androidResources {
@@ -134,39 +115,6 @@ android {
}
}
applicationVariants.all {
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
outputs
.mapNotNull { it as? BaseVariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> "$applicationId"
else -> output.outputFileName.removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName = "$fileNameWithoutExtension.apk"
val variantName = name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
tasks
.getByName("bundle${variantName.capitalize()}")
.finalizedBy(renameTaskName)
}
}
compileOptions {
sourceCompatibility(libs.versions.jvmTarget.get())
targetCompatibility(libs.versions.jvmTarget.get())
@@ -268,7 +216,6 @@ dependencies {
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.play.review)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.google.hilt.android.testing)
@@ -351,10 +298,6 @@ tasks {
dependsOn("detekt")
}
getByName("sonar") {
dependsOn("check")
}
withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
jvmTarget = libs.versions.jvmTarget.get()
}
@@ -367,16 +310,15 @@ tasks {
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC"
android.sourceSets["main"].res.srcDirs("src/test/res")
}
}
afterEvaluate {
// Disable Fdroid-specific tasks that we want to exclude
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +
val tasks = tasks.withType<GoogleServicesTask>() +
tasks.withType<InjectMappingFileIdTask>() +
tasks.withType<UploadMappingFileTask>()
fdroidTasksToDisable
tasks
.filter { it.name.contains("Fdroid") }
.forEach { it.enabled = false }
}
@@ -393,17 +335,8 @@ sonar {
}
}
private fun renameFile(path: String, newName: String) {
val originalFile = File(path)
if (!originalFile.exists()) {
println("File $originalFile does not exist!")
return
}
val newFile = File(originalFile.parentFile, newName)
if (originalFile.renameTo(newFile)) {
println("Renamed $originalFile to $newFile")
} else {
throw RuntimeException("Failed to rename $originalFile to $newFile")
tasks {
getByName("sonar") {
dependsOn("check")
}
}

View File

@@ -1,12 +0,0 @@
package com.x8bit.bitwarden.ui.platform.manager.review
import android.app.Activity
/**
* No-op implementation of [AppReviewManager] for F-Droid builds.
*/
class AppReviewManagerImpl(
activity: Activity,
) : AppReviewManager {
override fun promptForReview() = Unit
}

View File

@@ -1,17 +1,5 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "io.github.forkmaintainers.iceraven",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "9C:0D:22:37:9F:48:7B:70:A4:F9:F8:BE:C0:17:3C:F9:1A:16:44:F0:8F:93:38:5B:5B:78:2C:E3:76:60:BA:81"
}
]
}
},
{
"type": "android",
"info": {
@@ -36,18 +24,6 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.fenix",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "50:04:77:90:88:E7:F9:88:D5:BC:5C:C5:F8:79:8F:EB:F4:F8:CD:08:4A:1B:2A:46:EF:D4:C8:EE:4A:EA:F2:11"
}
]
}
},
{
"type": "android",
"info": {
@@ -87,6 +63,18 @@
}
]
}
},
{
"type": "android",
"info": {
"package_name": "io.github.forkmaintainers.iceraven",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "9C:0D:22:37:9F:48:7B:70:A4:F9:F8:BE:C0:17:3C:F9:1A:16:44:F0:8F:93:38:5B:5B:78:2C:E3:76:60:BA:81"
}
]
}
}
]
}

View File

@@ -15,6 +15,7 @@ import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
@@ -38,6 +39,9 @@ class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var accessibilityActivityManager: AccessibilityActivityManager
@Inject
lateinit var autofillActivityManager: AutofillActivityManager

View File

@@ -325,7 +325,7 @@ class MainViewModel @Inject constructor(
fido2CredentialManager.isUserVerified = false
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CreateCredentialRequest = fido2CredentialRequestData,
fido2CredentialRequest = fido2CredentialRequestData,
)
// Switch accounts if the selected user is not the active user.

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -172,16 +171,6 @@ interface AuthDiskSource {
pendingAuthRequest: PendingAuthRequestJson?,
)
/**
* Gets the biometrics initialization vector for the given [userId].
*/
fun getUserBiometricInitVector(userId: String): ByteArray?
/**
* Stores the biometrics initialization vector for the given [userId].
*/
fun storeUserBiometricInitVector(userId: String, iv: ByteArray?)
/**
* Gets the biometrics key for the given [userId].
*/
@@ -339,17 +328,7 @@ interface AuthDiskSource {
fun storeShowImportLogins(userId: String, showImportLogins: Boolean?)
/**
* Emits updates that track [getShowImportLogins]. This will replay the last known value.
* Emits updates that track [getShowImportLogins]. This will replay the last known value,
*/
fun getShowImportLoginsFlow(userId: String): Flow<Boolean?>
/**
* Gets the new device notice state for the given [userId].
*/
fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState
/**
* Stores the new device notice state for the given [userId].
*/
fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?)
}

View File

@@ -2,8 +2,6 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -23,7 +21,6 @@ import java.util.UUID
private const val ACCOUNT_TOKENS_KEY = "accountTokens"
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetric"
private const val AUTHENTICATOR_SYNC_UNLOCK_KEY = "authenticatorSyncUnlock"
private const val BIOMETRICS_INIT_VECTOR_KEY = "biometricInitializationVector"
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
private const val USER_AUTO_UNLOCK_KEY_KEY = "userKeyAutoUnlock"
private const val DEVICE_KEY_KEY = "deviceKey"
@@ -49,7 +46,6 @@ private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState"
/**
* Primary implementation of [AuthDiskSource].
@@ -146,7 +142,6 @@ class AuthDiskSourceImpl(
storePrivateKey(userId = userId, privateKey = null)
storeOrganizationKeys(userId = userId, organizationKeys = null)
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricInitVector(userId = userId, iv = null)
storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
@@ -282,17 +277,6 @@ class AuthDiskSourceImpl(
)
}
override fun getUserBiometricInitVector(userId: String): ByteArray? =
getEncryptedString(key = BIOMETRICS_INIT_VECTOR_KEY.appendIdentifier(userId))
?.toByteArray(Charsets.ISO_8859_1)
override fun storeUserBiometricInitVector(userId: String, iv: ByteArray?) {
putEncryptedString(
key = BIOMETRICS_INIT_VECTOR_KEY.appendIdentifier(userId),
value = iv?.toString(Charsets.ISO_8859_1),
)
}
override fun getUserBiometricUnlockKey(userId: String): String? =
getEncryptedString(key = BIOMETRICS_UNLOCK_KEY.appendIdentifier(userId))
@@ -487,22 +471,6 @@ class AuthDiskSourceImpl(
getMutableShowImportLoginsFlow(userId)
.onSubscription { emit(getShowImportLogins(userId)) }
override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState {
return getString(key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId))?.let {
json.decodeFromStringOrNull(it)
} ?: NewDeviceNoticeState(
displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
lastSeenDate = null,
)
}
override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) {
putString(
key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId),
value = newState?.let { json.encodeToString(it) },
)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()

View File

@@ -2,12 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.ZonedDateTime
/**
* Represents the current account information for a given user.
@@ -37,7 +35,6 @@ data class AccountJson(
* @property userId The ID of the user.
* @property email The user's email address.
* @property isEmailVerified Whether or not the user's email is verified.
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
@@ -49,7 +46,6 @@ data class AccountJson(
* @property kdfMemory The amount of memory to use when calculating a password hash (MB).
* @property kdfParallelism The number of threads to use when calculating a password hash.
* @property userDecryptionOptions The options available to a user for decryption.
* @property creationDate The creation date of the account.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@@ -63,9 +59,6 @@ data class AccountJson(
@SerialName("emailVerified")
val isEmailVerified: Boolean?,
@SerialName("isTwoFactorEnabled")
val isTwoFactorEnabled: Boolean?,
@SerialName("name")
val name: String?,
@@ -99,10 +92,6 @@ data class AccountJson(
@SerialName("userDecryptionOptions")
@JsonNames("accountDecryptionOptions")
val userDecryptionOptions: UserDecryptionOptionsJson?,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime?,
)
/**

View File

@@ -1,60 +0,0 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Describes the current display status of the new device notice screen.
*/
@Serializable
enum class NewDeviceNoticeDisplayStatus {
/**
* The user has seen the screen and indicated they can access their email.
*/
@SerialName("canAccessEmail")
CAN_ACCESS_EMAIL,
/**
* The user has indicated they can access their email
* as specified by the Permanent mode of the notice.
*/
@SerialName("canAccessEmailPermanent")
CAN_ACCESS_EMAIL_PERMANENT,
/**
* The user has not seen the screen.
*/
@SerialName("hasNotSeen")
HAS_NOT_SEEN,
/**
* The user has seen the screen and selected "remind me later".
*/
@SerialName("hasSeen")
HAS_SEEN,
}
/**
* The state of the new device notice screen.
*/
@Suppress("MagicNumber")
@Serializable
data class NewDeviceNoticeState(
@SerialName("displayStatus")
val displayStatus: NewDeviceNoticeDisplayStatus,
@SerialName("lastSeenDate")
@Contextual
val lastSeenDate: ZonedDateTime?,
) {
/**
* Whether the [lastSeenDate] is at least 7 days old.
*/
val shouldDisplayNoticeIfSeen = lastSeenDate
?.isBefore(
ZonedDateTime.now().minusDays(7),
)
?: false
}

View File

@@ -9,7 +9,6 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
@@ -69,7 +68,7 @@ interface IdentityService {
*/
suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<SendVerificationEmailResponseJson>
): Result<String?>
/**
* Register a new account to Bitwarden using email verification flow.

View File

@@ -11,7 +11,6 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
@@ -133,20 +132,11 @@ class IdentityServiceImpl(
override suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<SendVerificationEmailResponseJson> {
): Result<String?> {
return unauthenticatedIdentityApi
.sendVerificationEmail(body = body)
.toResult()
.map { SendVerificationEmailResponseJson.Success(it?.content) }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<SendVerificationEmailResponseJson.Invalid>(
code = 400,
json = json,
)
?: throw throwable
}
.map { it?.content }
}
override suspend fun verifyEmailRegistrationToken(

View File

@@ -55,5 +55,5 @@ class TrustedDeviceManagerImpl(
authDiskSource.storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = true)
}
.also { authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null) }
.map { }
.map { Unit }
}

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
@@ -402,19 +401,4 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* Update the value of the onboarding status for the user.
*/
fun setOnboardingStatus(userId: String, status: OnboardingStatus?)
/**
* Checks if a new device notice should be displayed.
*/
fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean
/**
* Gets the new device notice state of active user.
*/
fun getNewDeviceNoticeState(): NewDeviceNoticeState?
/**
* Stores the new device notice state for active user.
*/
fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?)
}

View File

@@ -2,14 +2,13 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
@@ -24,7 +23,6 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
@@ -96,7 +94,6 @@ import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITER
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isSslHandShakeError
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
@@ -108,7 +105,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
import com.x8bit.bitwarden.data.platform.util.asFailure
@@ -118,6 +114,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
@@ -144,7 +141,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.ZonedDateTime
import javax.inject.Singleton
/**
@@ -629,12 +625,7 @@ class AuthRepositoryImpl(
)
}
.fold(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
else -> LoginResult.Error(errorMessage = null)
}
},
onFailure = { LoginResult.Error(errorMessage = null) },
onSuccess = { it },
)
@@ -1258,17 +1249,41 @@ class AuthRepositoryImpl(
?.activeAccount
?.profile
?: return ValidatePinResult.Error
val privateKey = authDiskSource
.getPrivateKey(userId = activeAccount.userId)
?: return ValidatePinResult.Error
val pinProtectedUserKey = authDiskSource
.getPinProtectedUserKey(userId = activeAccount.userId)
?: return ValidatePinResult.Error
// HACK: As the SDK doesn't provide a way to directly validate the pin yet, we instead
// try to initialize the user crypto, and if it succeeds then the PIN is correct, otherwise
// the PIN is incorrect.
return vaultSdkSource
.validatePin(
.initializeCrypto(
userId = activeAccount.userId,
pin = pin,
pinProtectedUserKey = pinProtectedUserKey,
request = InitUserCryptoRequest(
kdfParams = activeAccount.toSdkParams(),
email = activeAccount.email,
privateKey = privateKey,
method = InitUserCryptoMethod.Pin(
pin = pin,
pinProtectedUserKey = pinProtectedUserKey,
),
),
)
.fold(
onSuccess = { ValidatePinResult.Success(isValid = it) },
onSuccess = {
when (it) {
InitializeCryptoResult.Success -> {
ValidatePinResult.Success(isValid = true)
}
is InitializeCryptoResult.AuthenticationError -> {
ValidatePinResult.Success(isValid = false)
}
}
},
onFailure = { ValidatePinResult.Error },
)
}
@@ -1293,15 +1308,7 @@ class AuthRepositoryImpl(
)
.fold(
onSuccess = {
when (it) {
is SendVerificationEmailResponseJson.Invalid -> {
SendVerificationEmailResult.Error(it.message)
}
is SendVerificationEmailResponseJson.Success -> {
SendVerificationEmailResult.Success(it.emailVerificationToken)
}
}
SendVerificationEmailResult.Success(it)
},
onFailure = {
SendVerificationEmailResult.Error(null)
@@ -1337,91 +1344,6 @@ class AuthRepositoryImpl(
authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
}
override fun getNewDeviceNoticeState(): NewDeviceNoticeState? {
return activeUserId?.let { userId ->
authDiskSource.getNewDeviceNoticeState(userId = userId)
}
}
override fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?) {
activeUserId?.let { userId ->
authDiskSource.storeNewDeviceNoticeState(userId = userId, newState = newState)
}
}
override fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean {
return activeUserId?.let { userId ->
val temporaryFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
val permanentFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
// check if feature flags are disabled
if (!temporaryFlag && !permanentFlag) {
return false
}
if (!newDeviceNoticePreConditionsValid()) {
return false
}
val newDeviceNoticeState = authDiskSource.getNewDeviceNoticeState(userId = userId)
return when (newDeviceNoticeState.displayStatus) {
// if the user has already attested email access but permanent flag is enabled,
// the notice needs to appear again
NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL -> permanentFlag
// if the user has already seen but 7 days have already passed,
// the notice needs to appear again
NewDeviceNoticeDisplayStatus.HAS_SEEN ->
newDeviceNoticeState.shouldDisplayNoticeIfSeen
NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN -> true
// the user never needs to see the notice again
NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT -> false
}
}
?: false
}
/**
* Checks if the preconditions are met for a user to see a new device notice:
* - Must be a Bitwarden cloud user.
* - The account must be at least one week old.
* - Cannot have an active policy requiring SSO to be enabled.
* - Cannot have two-factor authentication enabled.
*/
private fun newDeviceNoticePreConditionsValid(): Boolean {
val checkEnvironment = !featureFlagManager.getFeatureFlag(FlagKey.IgnoreEnvironmentCheck)
val isSelfHosted = environmentRepository.environment.type == Environment.Type.SELF_HOSTED
if (checkEnvironment && isSelfHosted) {
return false
}
val userProfile = authDiskSource.userState?.activeAccount?.profile
val isProfileAtLeastWeekOld = userProfile
?.let {
it.creationDate
?.plusWeeks(1)
?.isBefore(
ZonedDateTime.now(),
)
}
?: false
if (!isProfileAtLeastWeekOld) {
return false
}
val hasTwoFactorEnabled = userProfile
?.isTwoFactorEnabled
?: false
if (hasTwoFactorEnabled) {
return false
}
val hasSSOPolicy =
policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
.any { p -> p.isEnabled }
return !hasSSOPolicy
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
@@ -1586,12 +1508,9 @@ class AuthRepositoryImpl(
captchaToken = captchaToken,
)
.fold(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
configDiskSource.serverConfig?.isOfficialBitwardenServer == false -> {
LoginResult.UnofficialServerError
}
onFailure = {
when (configDiskSource.serverConfig?.isOfficialBitwardenServer) {
false -> LoginResult.UnofficialServerError
else -> LoginResult.Error(errorMessage = null)
}
},

View File

@@ -28,9 +28,4 @@ sealed class LoginResult {
* There was an error while logging into an unofficial Bitwarden server.
*/
data object UnofficialServerError : LoginResult()
/**
* There was an error in validating the certificate chain for the server
*/
data object CertificateError : LoginResult()
}

View File

@@ -18,5 +18,4 @@ data class Organization(
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
val shouldUsersGetPremium: Boolean,
)

View File

@@ -25,7 +25,6 @@ fun GetTokenResponseJson.Success.toUserState(
userId = userId,
email = jwtTokenData.email,
isEmailVerified = jwtTokenData.isEmailVerified,
isTwoFactorEnabled = null,
name = jwtTokenData.name,
stamp = null,
organizationId = null,
@@ -37,7 +36,6 @@ fun GetTokenResponseJson.Success.toUserState(
kdfMemory = this.kdfMemory,
kdfParallelism = this.kdfParallelism,
userDecryptionOptions = this.userDecryptionOptions,
creationDate = null,
),
settings = AccountJson.Settings(
environmentUrlData = environmentUrlData,

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull
import kotlinx.serialization.json.Json
import timber.log.Timber
/**
* Internal, generally basic [Json] instance for JWT parsing purposes.
@@ -18,24 +17,17 @@ private val json: Json by lazy {
/**
* Parses a [JwtTokenDataJson] from the given [jwtToken], or `null` if this parsing is not possible.
*/
@Suppress("MagicNumber", "TooGenericExceptionCaught")
@Suppress("MagicNumber")
fun parseJwtTokenDataOrNull(jwtToken: String): JwtTokenDataJson? {
val parts = jwtToken.split(".")
if (parts.size != 3) {
Timber.e(IllegalArgumentException("Incorrect number of parts"), "Invalid JWT Token")
return null
}
if (parts.size != 3) return null
val dataJson = parts[1]
val decodedDataJson = dataJson.base64UrlDecodeOrNull() ?: run {
Timber.e(IllegalArgumentException("Unable to decode"), "Invalid JWT Token")
return null
}
val decodedDataJson = dataJson.base64UrlDecodeOrNull() ?: return null
return try {
json.decodeFromString<JwtTokenDataJson>(decodedDataJson)
} catch (throwable: Throwable) {
Timber.e(throwable, "Failed to decode JwtTokenDataJson")
} catch (_: Throwable) {
null
}
}

View File

@@ -22,7 +22,6 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
shouldUsersGetPremium = this.shouldUsersGetPremium,
)
/**

View File

@@ -59,8 +59,6 @@ fun UserStateJson.toUpdatedUserStateJson(
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.autofill.accessibility.di
import android.content.Context
import android.content.pm.PackageManager
import android.os.PowerManager
import android.view.accessibility.AccessibilityManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
@@ -56,12 +55,8 @@ object AccessibilityModule {
@Singleton
@Provides
fun providesAccessibilityEnabledManager(
accessibilityManager: AccessibilityManager,
): AccessibilityEnabledManager =
AccessibilityEnabledManagerImpl(
accessibilityManager = accessibilityManager,
)
fun providesAccessibilityEnabledManager(): AccessibilityEnabledManager =
AccessibilityEnabledManagerImpl()
@Singleton
@Provides
@@ -115,12 +110,6 @@ object AccessibilityModule {
@ApplicationContext context: Context,
): PackageManager = context.packageManager
@Singleton
@Provides
fun provideAccessibilityManager(
@ApplicationContext context: Context,
): AccessibilityManager = context.getSystemService(AccessibilityManager::class.java)
@Singleton
@Provides
fun providesPowerManager(

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,15 @@ import kotlinx.coroutines.flow.StateFlow
*/
interface AccessibilityEnabledManager {
/**
* Emits updates that track whether the accessibility autofill service is enabled..
* Whether or not the accessibility service should be considered enabled.
*
* Note that changing this does not enable or disable autofill; it is only an indicator that
* this has occurred elsewhere.
*/
var isAccessibilityEnabled: Boolean
/**
* Emits updates that track [isAccessibilityEnabled] values.
*/
val isAccessibilityEnabledStateFlow: StateFlow<Boolean>
}

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.view.accessibility.AccessibilityManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -8,18 +7,14 @@ import kotlinx.coroutines.flow.asStateFlow
/**
* The default implementation of [AccessibilityEnabledManager].
*/
class AccessibilityEnabledManagerImpl(
accessibilityManager: AccessibilityManager,
) : AccessibilityEnabledManager {
class AccessibilityEnabledManagerImpl : AccessibilityEnabledManager {
private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(value = false)
init {
accessibilityManager.addAccessibilityStateChangeListener(
AccessibilityManager.AccessibilityStateChangeListener { isEnabled ->
mutableIsAccessibilityEnabledStateFlow.value = isEnabled
},
)
}
override var isAccessibilityEnabled: Boolean
get() = mutableIsAccessibilityEnabledStateFlow.value
set(value) {
mutableIsAccessibilityEnabledStateFlow.value = value
}
override val isAccessibilityEnabledStateFlow: StateFlow<Boolean>
get() = mutableIsAccessibilityEnabledStateFlow.asStateFlow()

View File

@@ -9,5 +9,5 @@ data class FillableFields(
val usernameField: AccessibilityNodeInfo?,
val passwordFields: List<AccessibilityNodeInfo>,
) {
val hasFields: Boolean = usernameField != null || passwordFields.isNotEmpty()
val hasFields: Boolean = usernameField != null && passwordFields.isNotEmpty()
}

View File

@@ -8,8 +8,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
@@ -60,26 +58,17 @@ object Fido2ProviderModule {
@Provides
@Singleton
fun provideFido2CredentialManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
vaultSdkSource: VaultSdkSource,
fido2CredentialStore: Fido2CredentialStore,
fido2OriginManager: Fido2OriginManager,
json: Json,
): Fido2CredentialManager =
Fido2CredentialManagerImpl(
vaultSdkSource = vaultSdkSource,
fido2CredentialStore = fido2CredentialStore,
fido2OriginManager = fido2OriginManager,
json = json,
)
@Provides
@Singleton
fun provideFido2OriginManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
): Fido2OriginManager =
Fido2OriginManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = vaultSdkSource,
fido2CredentialStore = fido2CredentialStore,
json = json,
)
}

View File

@@ -1,10 +1,12 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
@@ -24,6 +26,14 @@ interface Fido2CredentialManager {
*/
var authenticationAttempts: Int
/**
* Attempt to validate the RP and origin of the provided [callingAppInfo] and [relyingPartyId].
*/
suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult
/**
* Attempt to extract FIDO 2 passkey attestation options from the system [requestJson], or null.
*/
@@ -43,7 +53,7 @@ interface Fido2CredentialManager {
*/
suspend fun registerFido2Credential(
userId: String,
fido2CreateCredentialRequest: Fido2CreateCredentialRequest,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult

View File

@@ -6,17 +6,21 @@ import com.bitwarden.fido.Origin
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.data.platform.util.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
@@ -27,14 +31,18 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json"
/**
* Primary implementation of [Fido2CredentialManager].
*/
@Suppress("TooManyFunctions")
class Fido2CredentialManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val vaultSdkSource: VaultSdkSource,
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2OriginManager: Fido2OriginManager,
private val json: Json,
) : Fido2CredentialManager,
Fido2CredentialStore by fido2CredentialStore {
@@ -45,31 +53,31 @@ class Fido2CredentialManagerImpl(
override suspend fun registerFido2Credential(
userId: String,
fido2CreateCredentialRequest: Fido2CreateCredentialRequest,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult {
val clientData = if (fido2CreateCredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CreateCredentialRequest
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CreateCredentialRequest
androidPackageName = fido2CredentialRequest
.callingAppInfo
.packageName,
)
}
val assetLinkUrl = fido2CreateCredentialRequest
val assetLinkUrl = fido2CredentialRequest
.origin
?: getOriginUrlFromAttestationOptionsOrNull(fido2CreateCredentialRequest.requestJson)
?: getOriginUrlFromAttestationOptionsOrNull(fido2CredentialRequest.requestJson)
?: return Fido2RegisterCredentialResult.Error
val origin = Origin.Android(
UnverifiedAssetLink(
packageName = fido2CreateCredentialRequest.packageName,
sha256CertFingerprint = fido2CreateCredentialRequest
packageName = fido2CredentialRequest.packageName,
sha256CertFingerprint = fido2CredentialRequest
.callingAppInfo
.getSignatureFingerprintAsHexString()
?: return Fido2RegisterCredentialResult.Error,
@@ -83,7 +91,7 @@ class Fido2CredentialManagerImpl(
request = RegisterFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${fido2CreateCredentialRequest.requestJson}}""",
requestJson = """{"publicKey": ${fido2CredentialRequest.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
// User verification is handled prior to engaging the SDK. We always respond
@@ -100,14 +108,16 @@ class Fido2CredentialManagerImpl(
)
}
private suspend fun validateOrigin(
override suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = callingAppInfo,
relyingPartyId = relyingPartyId,
)
): Fido2ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
}
}
override fun getPasskeyAttestationOptionsOrNull(
requestJson: String,
@@ -158,7 +168,7 @@ class Fido2CredentialManagerImpl(
Fido2CredentialAssertionResult.Error
}
is Fido2ValidateOriginResult.Success -> {
Fido2ValidateOriginResult.Success -> {
vaultSdkSource
.authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest(
@@ -190,6 +200,127 @@ class Fido2CredentialManagerImpl(
}
}
private suspend fun validateCallingApplicationAssetLinks(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return digitalAssetLinkService
.getDigitalAssetLinkForRp(relyingParty = relyingPartyId)
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
.map { statements ->
statements
.filterMatchingAppStatementsOrNull(
rpPackageName = callingAppInfo.packageName,
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.map { matchingStatements ->
callingAppInfo
.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
}
.fold(
onSuccess = {
Fido2ValidateOriginResult.Success
},
onFailure = {
Fido2ValidateOriginResult.Error.Unknown
},
)
}
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult {
val googleAllowListResult =
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
return when (googleAllowListResult) {
is Fido2ValidateOriginResult.Success -> {
// Application was found and successfully validated against the Google allow list so
// we can return the result as the final validation result.
googleAllowListResult
}
is Fido2ValidateOriginResult.Error -> {
// Check the community allow list if the Google allow list failed, and return the
// result as the final validation result.
validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
}
}
}
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = GOOGLE_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithAllowList(
callingAppInfo: CallingAppInfo,
fileName: String,
): Fido2ValidateOriginResult =
assetManager
.readAsset(fileName)
.map { allowList ->
callingAppInfo.validatePrivilegedApp(
allowList = allowList,
)
}
.fold(
onSuccess = { it },
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppStatementsOrNull(
rpPackageName: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
val target = statement.target
target.namespace == "android_app" &&
target.packageName == rpPackageName &&
statement.relation.containsAll(
listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
)
}
.takeUnless { it.isEmpty() }
/**
* Returns statements that match the given [signature], or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppSignaturesOrNull(
signature: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
statement.target.sha256CertFingerprints
?.contains(signature)
?: false
}
.takeUnless { it.isEmpty() }
override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS

View File

@@ -1,32 +0,0 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
/**
* Responsible for managing FIDO2 origin validation.
*/
interface Fido2OriginManager {
/**
* Validates the origin of a calling app.
*
* @param callingAppInfo The calling app info.
* @param relyingPartyId The relying party ID.
*
* @return The result of the validation.
*/
suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult
/**
* Returns the privileged app origin, or null if the calling app is not allowed.
*
* @param callingAppInfo The calling app info.
*
* @return The privileged app origin, or null.
*/
suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String?
}

View File

@@ -1,172 +0,0 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber
private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json"
/**
* Primary implementation of [Fido2OriginManager].
*/
@Suppress("TooManyFunctions")
class Fido2OriginManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
) : Fido2OriginManager {
override suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
}
}
override suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String? {
if (!callingAppInfo.isOriginPopulated()) return null
return callingAppInfo.getOrigin(getGoogleAllowListOrNull().orEmpty())
?: callingAppInfo.getOrigin(getCommunityAllowListOrNull().orEmpty())
?.takeUnless { !callingAppInfo.isOriginPopulated() }
}
private suspend fun validateCallingApplicationAssetLinks(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult = digitalAssetLinkService
.getDigitalAssetLinkForRp(relyingParty = relyingPartyId)
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
.mapCatching { statements ->
statements
.filterMatchingAppStatementsOrNull(
rpPackageName = callingAppInfo.packageName,
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.mapCatching { matchingStatements ->
callingAppInfo
.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified
}
.fold(
onSuccess = {
Fido2ValidateOriginResult.Success(null)
},
onFailure = {
Fido2ValidateOriginResult.Error.Unknown
},
)
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult {
val googleAllowListResult =
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
return when (googleAllowListResult) {
is Fido2ValidateOriginResult.Success -> {
// Application was found and successfully validated against the Google allow list so
// we can return the result as the final validation result.
googleAllowListResult
}
is Fido2ValidateOriginResult.Error -> {
// Check the community allow list if the Google allow list failed, and return the
// result as the final validation result.
validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
}
}
}
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = GOOGLE_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithAllowList(
callingAppInfo: CallingAppInfo,
fileName: String,
): Fido2ValidateOriginResult =
assetManager
.readAsset(fileName)
.mapCatching { allowList ->
callingAppInfo.validatePrivilegedApp(
allowList = allowList,
)
}
.fold(
onSuccess = { it },
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppStatementsOrNull(
rpPackageName: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
val target = statement.target
target.namespace == "android_app" &&
target.packageName == rpPackageName &&
statement.relation.containsAll(
listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
)
}
.takeUnless { it.isEmpty() }
/**
* Returns statements that match the given [signature], or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppSignaturesOrNull(
signature: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
statement.target.sha256CertFingerprints
?.contains(signature)
?: false
}
.takeUnless { it.isEmpty() }
private suspend fun getGoogleAllowListOrNull(): String? =
assetManager
.readAsset(GOOGLE_ALLOW_LIST_FILE_NAME)
.onFailure { Timber.e(it, "Failed to read Google allow list.") }
.getOrNull()
private suspend fun getCommunityAllowListOrNull(): String? =
assetManager
.readAsset(COMMUNITY_ALLOW_LIST_FILE_NAME)
.onFailure { Timber.e(it, "Failed to read Community allow list.") }
.getOrNull()
}

View File

@@ -14,7 +14,7 @@ import kotlinx.parcelize.Parcelize
* @property callingAppInfo Information about the application that initiated the request.
*/
@Parcelize
data class Fido2CreateCredentialRequest(
data class Fido2CredentialRequest(
val userId: String,
val requestJson: String,
val packageName: String,

View File

@@ -1,8 +1,5 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import androidx.annotation.StringRes
import com.x8bit.bitwarden.R
/**
* Models the result of validating the origin of a FIDO2 request.
*/
@@ -10,75 +7,49 @@ sealed class Fido2ValidateOriginResult {
/**
* Represents a successful origin validation.
*
* @param origin The origin of the calling app, or null if the calling app is not privileged.
*/
data class Success(val origin: String?) : Fido2ValidateOriginResult()
data object Success : Fido2ValidateOriginResult()
/**
* Represents a validation error.
*/
sealed class Error : Fido2ValidateOriginResult() {
/**
* The string resource ID of the error message.
*/
@get:StringRes
abstract val messageResId: Int
/**
* Indicates the digital asset links file could not be located.
*/
data object AssetLinkNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_of_missing_asset_links
}
data object AssetLinkNotFound : Error()
/**
* Indicates the application package name was not found in the digital asset links file.
*/
data object ApplicationNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
}
data object ApplicationNotFound : Error()
/**
* Indicates the application fingerprint was not found the digital asset links file.
*/
data object ApplicationFingerprintNotVerified : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_app_could_not_be_verified
}
data object ApplicationNotVerified : Error()
/**
* Indicates the calling application is privileged but its package name is not found within
* the privileged app allow list.
*/
data object PrivilegedAppNotAllowed : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_browser_is_not_privileged
}
data object PrivilegedAppNotAllowed : Error()
/**
* Indicates the calling app is privileged but but no matching signing certificate signature
* is present in the allow list.
*/
data object PrivilegedAppSignatureNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_browser_signature_does_not_match
}
data object PrivilegedAppSignatureNotFound : Error()
/**
* Indicates passkeys are not supported for the requesting application.
*/
data object PasskeyNotSupportedForApp : Error() {
override val messageResId = R.string.passkeys_not_supported_for_this_app
}
data object PasskeyNotSupportedForApp : Error()
/**
* Indicates an unknown error was encountered while validating the origin.
*/
data object Unknown : Error() {
override val messageResId = R.string.generic_error_message
}
data object Unknown : Error()
}
}

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.autofill.fido2.processor
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import android.os.CancellationSignal
import android.os.OutcomeReceiver
@@ -28,20 +27,16 @@ import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
@@ -228,14 +223,10 @@ class Fido2ProviderProcessorImpl(
): List<CredentialEntry> {
val cipherViews = vaultRepository
.ciphersStateFlow
.takeUntilLoaded()
.fold(emptyList<CipherView>()) { _, dataState ->
when (dataState) {
is DataState.Loaded -> dataState.data.filter { it.isActiveWithFido2Credentials }
else -> emptyList()
}
}
.value
.data
?.filter { it.isActiveWithFido2Credentials }
?: emptyList()
val result = vaultRepository
.getDecryptedFido2CredentialAutofillViews(cipherViews)
return when (result) {
@@ -275,13 +266,6 @@ class Fido2ProviderProcessorImpl(
),
beginGetPublicKeyCredentialOption = option,
)
.setIcon(
Icon
.createWithResource(
context,
R.drawable.ic_bw_passkey,
),
)
.build()
}

View File

@@ -6,8 +6,8 @@ import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.PendingIntentHandler
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID
@@ -15,10 +15,10 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
/**
* Checks if this [Intent] contains a [Fido2CreateCredentialRequest] related to an ongoing FIDO 2
* Checks if this [Intent] contains a [Fido2CredentialRequest] related to an ongoing FIDO 2
* credential creation process.
*/
fun Intent.getFido2CredentialRequestOrNull(): Fido2CreateCredentialRequest? {
fun Intent.getFido2CredentialRequestOrNull(): Fido2CredentialRequest? {
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
val systemRequest = PendingIntentHandler
@@ -33,7 +33,7 @@ fun Intent.getFido2CredentialRequestOrNull(): Fido2CreateCredentialRequest? {
val userId = getStringExtra(EXTRA_KEY_USER_ID)
?: return null
return Fido2CreateCredentialRequest(
return Fido2CredentialRequest(
userId = userId,
requestJson = createPublicKeyRequest.requestJson,
packageName = systemRequest.callingAppInfo.packageName,

View File

@@ -9,7 +9,6 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.ui.vault.feature.vault.util.getOrganizationPremiumStatusMap
import java.time.Clock
/**
@@ -25,15 +24,8 @@ class AutofillTotpManagerImpl(
) : AutofillTotpManager {
override suspend fun tryCopyTotpToClipboard(cipherView: CipherView) {
if (settingsRepository.isAutoCopyTotpDisabled) return
val organizationPremiumStatusMap = authRepository
.userStateFlow
.value
?.activeAccount
?.getOrganizationPremiumStatusMap()
.orEmpty()
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
val premiumStatus = organizationPremiumStatusMap[cipherView.organizationId] ?: isPremium
if (!premiumStatus && !cipherView.organizationUseTotp) return
if (!isPremium && !cipherView.organizationUseTotp) return
val totpCode = cipherView.login?.totp ?: return
val totpResult = vaultRepository.generateTotp(

View File

@@ -2,7 +2,6 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.Activity
import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
@@ -148,12 +147,3 @@ fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? =
fun Intent.getTotpCopyIntentOrNull(): AutofillTotpCopyData? =
getBundleExtra(AUTOFILL_BUNDLE_KEY)
?.getSafeParcelableExtra(AUTOFILL_TOTP_COPY_DATA_KEY)
/**
* Checks if the given [Activity] was created for Autofill. This is useful to avoid locking the
* vault if one of the Autofill services starts the only instance of the [MainActivity].
*/
val Activity.createdForAutofill: Boolean
get() = intent.getAutofillSelectionDataOrNull() != null ||
intent.getAutofillSaveItemOrNull() != null ||
intent.getAutofillAssistStructureOrNull() != null

View File

@@ -24,6 +24,7 @@ fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem =
.uri
?.replace("https://", "")
?.replace("http://", "")
?.replace("androidapp://", "")
AutofillSaveItem.Login(
username = partition.usernameSaveValue,

View File

@@ -68,6 +68,17 @@ interface SettingsDiskSource {
*/
val hasUserLoggedInOrCreatedAccountFlow: Flow<Boolean?>
/**
* The instant when the last database scheme change was applied. `null` if no scheme changes
* have been applied yet.
*/
var lastDatabaseSchemeChangeInstant: Instant?
/**
* Emits updates that track [lastDatabaseSchemeChangeInstant].
*/
val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
/**
* Clears all the settings data for the given user.
*/
@@ -308,52 +319,4 @@ interface SettingsDiskSource {
* Emits updates that track [getShowImportLoginsSettingBadge] for the given [userId].
*/
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether or not the given [userId] has registered for export via the credential exchange
* protocol.
*/
fun getVaultRegisteredForExport(userId: String): Boolean?
/**
* Stores the given value for whether or not the given [userId] has registered for export via
* the credential exchange protocol.
*/
fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?)
/**
* Emits updates that track [getVaultRegisteredForExport] for the given [userId].
*/
fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?>
/**
* Gets the number of qualifying add cipher actions for the device.
*/
fun getAddCipherActionCount(): Int?
/**
* Stores the given [count] completed "add" cipher actions taken place on the device.
*/
fun storeAddCipherActionCount(count: Int?)
/**
* Gets the number of qualifying generated result actions for the device.
*/
fun getGeneratedResultActionCount(): Int?
/**
* Stores the given [count] completed generated password or username result actions taken
* for the device.
*/
fun storeGeneratedResultActionCount(count: Int?)
/**
* Gets the number of qualifying create send actions for the device.
*/
fun getCreateSendActionCount(): Int?
/**
* Stores the given [count] completed create send actions for the device.
*/
fun storeCreateSendActionCount(count: Int?)
}

View File

@@ -36,10 +36,7 @@ private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedI
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
private const val SHOW_UNLOCK_SETTING_BADGE = "showUnlockSettingBadge"
private const val SHOW_IMPORT_LOGINS_SETTING_BADGE = "showImportLoginsSettingBadge"
private const val IS_VAULT_REGISTERED_FOR_EXPORT = "isVaultRegisteredForExport"
private const val ADD_ACTION_COUNT = "addActionCount"
private const val COPY_ACTION_COUNT = "copyActionCount"
private const val CREATE_ACTION_COUNT = "createActionCount"
private const val LAST_SCHEME_CHANGE_INSTANT = "lastDatabaseSchemeChangeInstant"
/**
* Primary implementation of [SettingsDiskSource].
@@ -78,10 +75,9 @@ class SettingsDiskSourceImpl(
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableLastDatabaseSchemeChangeInstantFlow = bufferedMutableSharedFlow<Instant?>()
private val mutableVaultRegisteredForExportFlow =
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
override var appLanguage: AppLanguage?
@@ -162,6 +158,17 @@ class SettingsDiskSourceImpl(
get() = mutableHasUserLoggedInOrCreatedAccountFlow
.onSubscription { emit(getBoolean(HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY)) }
override var lastDatabaseSchemeChangeInstant: Instant?
get() = getLong(LAST_SCHEME_CHANGE_INSTANT)?.let { Instant.ofEpochMilli(it) }
set(value) {
putLong(LAST_SCHEME_CHANGE_INSTANT, value?.toEpochMilli())
mutableLastDatabaseSchemeChangeInstantFlow.tryEmit(value)
}
override val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
get() = mutableLastDatabaseSchemeChangeInstantFlow
.onSubscription { emit(lastDatabaseSchemeChangeInstant) }
override fun clearData(userId: String) {
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)
storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null)
@@ -174,7 +181,6 @@ class SettingsDiskSourceImpl(
storeLastSyncTime(userId = userId, lastSyncTime = null)
storeClearClipboardFrequencySeconds(userId = userId, frequency = null)
removeWithPrefix(prefix = ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY.appendIdentifier(userId))
storeVaultRegisteredForExport(userId = userId, isRegistered = null)
// The following are intentionally not cleared so they can be
// restored after logging out and back in:
@@ -437,51 +443,6 @@ class SettingsDiskSourceImpl(
getMutableShowImportLoginsSettingBadgeFlow(userId)
.onSubscription { emit(getShowImportLoginsSettingBadge(userId)) }
override fun getVaultRegisteredForExport(userId: String): Boolean? =
getBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId))
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?) {
putBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId), isRegistered)
getMutableVaultRegisteredForExportFlow(userId).tryEmit(isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?> =
getMutableVaultRegisteredForExportFlow(userId)
.onSubscription { emit(getVaultRegisteredForExport(userId)) }
override fun getAddCipherActionCount(): Int? = getInt(
key = ADD_ACTION_COUNT,
)
override fun storeAddCipherActionCount(count: Int?) {
putInt(
key = ADD_ACTION_COUNT,
value = count,
)
}
override fun getGeneratedResultActionCount(): Int? = getInt(
key = COPY_ACTION_COUNT,
)
override fun storeGeneratedResultActionCount(count: Int?) {
putInt(
key = COPY_ACTION_COUNT,
value = count,
)
}
override fun getCreateSendActionCount(): Int? = getInt(
key = CREATE_ACTION_COUNT,
)
override fun storeCreateSendActionCount(count: Int?) {
putInt(
key = CREATE_ACTION_COUNT,
value = count,
)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =
@@ -532,10 +493,4 @@ class SettingsDiskSourceImpl(
mutableShowImportLoginsSettingBadgeFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableVaultRegisteredForExportFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableVaultRegisteredForExportFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
}

View File

@@ -172,7 +172,7 @@ private class AndroidKeyStore(
private val useSymmetricPreferenceKey: String = "essentials_use_symmetric"
private val prefsMasterKey = "SecureStorageKey"
private val initializationVectorLen = 12 // Android supports an IV of 12 for AES/GCM
private val initializationVectorLen = 12; // Android supports an IV of 12 for AES/GCM
init {
keyStore.load(null)

View File

@@ -6,26 +6,37 @@ import okhttp3.Interceptor
import okhttp3.Response
/**
* An [Interceptor] that optionally takes the current base URL of a request and replaces it with
* the currently set base URL from the [baseUrlProvider].
* A [Interceptor] that optionally takes the current base URL of a request and replaces it with
* the currently set [baseUrl]
*/
class BaseUrlInterceptor(
private val baseUrlProvider: () -> String?,
) : Interceptor {
class BaseUrlInterceptor : Interceptor {
private val baseHttpUrl: HttpUrl? get() = baseUrlProvider()?.toHttpUrlOrNull()
/**
* The base URL to use as an override, or `null` if no override should be performed.
*/
var baseUrl: String? = null
set(value) {
field = value
baseHttpUrl = baseUrl?.let { requireNotNull(it.toHttpUrlOrNull()) }
}
private var baseHttpUrl: HttpUrl? = null
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// If no base URL is set, we can simply skip
val base = baseHttpUrl ?: return chain.proceed(request = request)
val base = baseHttpUrl ?: return chain.proceed(request)
// Update the base URL used.
return chain.proceed(
request = request
request
.newBuilder()
.url(url = request.url.replaceBaseUrlWith(baseUrl = base))
.url(
request
.url
.replaceBaseUrlWith(base),
)
.build(),
)
}

View File

@@ -1,44 +1,47 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseApiUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseEventsUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseIdentityUrl
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault
import javax.inject.Inject
import javax.inject.Singleton
/**
* An overall container for various [BaseUrlInterceptor] implementations for different API groups.
*/
@OmitFromCoverage
@Singleton
class BaseUrlInterceptors @Inject constructor(
private val environmentDiskSource: EnvironmentDiskSource,
) {
private val environment: Environment
get() = environmentDiskSource.preAuthEnvironmentUrlData.toEnvironmentUrlsOrDefault()
class BaseUrlInterceptors @Inject constructor() {
var environment: Environment = Environment.Us
set(value) {
field = value
updateBaseUrls(environment = value)
}
/**
* An interceptor for "/api" calls.
*/
val apiInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
environment.environmentUrlData.baseApiUrl
}
val apiInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
/**
* An interceptor for "/identity" calls.
*/
val identityInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
environment.environmentUrlData.baseIdentityUrl
}
val identityInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
/**
* An interceptor for "/events" calls.
*/
val eventsInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
environment.environmentUrlData.baseEventsUrl
val eventsInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
init {
// Ensure all interceptors begin with a default value
environment = Environment.Us
}
private fun updateBaseUrls(environment: Environment) {
val environmentUrlData = environment.environmentUrlData
apiInterceptor.baseUrl = environmentUrlData.baseApiUrl
identityInterceptor.baseUrl = environmentUrlData.baseIdentityUrl
eventsInterceptor.baseUrl = environmentUrlData.baseEventsUrl
}
}

View File

@@ -3,9 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.util
import okio.ByteString.Companion.decodeBase64
import java.net.UnknownHostException
import java.nio.charset.Charset
import java.security.cert.CertPathValidatorException
import java.util.Base64
import javax.net.ssl.SSLHandshakeException
/**
* Base 64 encode the string as well as make special modifications required by the backend:
@@ -43,12 +41,3 @@ fun Throwable?.isNoConnectionError(): Boolean {
return this is UnknownHostException ||
this?.cause?.isNoConnectionError() ?: false
}
/**
* Returns true if the throwable represents a SSL handshake error.
*/
fun Throwable?.isSslHandShakeError(): Boolean {
return this is SSLHandshakeException ||
this is CertPathValidatorException ||
this?.cause?.isSslHandShakeError() ?: false
}

View File

@@ -6,7 +6,6 @@ import android.os.Bundle
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.x8bit.bitwarden.data.autofill.util.createdForAutofill
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,8 +19,7 @@ class AppStateManagerImpl(
application: Application,
processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(),
) : AppStateManager {
private val mutableAppCreationStateFlow =
MutableStateFlow<AppCreationState>(AppCreationState.Destroyed)
private val mutableAppCreationStateFlow = MutableStateFlow(AppCreationState.DESTROYED)
private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED)
override val appCreatedStateFlow: StateFlow<AppCreationState>
@@ -51,15 +49,13 @@ class AppStateManagerImpl(
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activityCount++
// Always be in a created state if we have an activity
mutableAppCreationStateFlow.value = AppCreationState.Created(
isAutoFill = activity.createdForAutofill,
)
mutableAppCreationStateFlow.value = AppCreationState.CREATED
}
override fun onActivityDestroyed(activity: Activity) {
activityCount--
if (activityCount == 0 && !activity.isChangingConfigurations) {
mutableAppCreationStateFlow.value = AppCreationState.Destroyed
mutableAppCreationStateFlow.value = AppCreationState.DESTROYED
}
}

View File

@@ -20,6 +20,12 @@ interface BiometricsEncryptionManager {
userId: String,
): Cipher?
/**
* Sets up biometrics to ensure future integrity checks work properly. If this method has never
* been called [isBiometricIntegrityValid] will return false.
*/
fun setupBiometrics(userId: String)
/**
* Checks to verify that the biometrics integrity is still valid. This returns `true` if the
* biometrics data has not changed since the app setup biometrics; `false` will be returned if

View File

@@ -4,9 +4,9 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import java.io.IOException
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.KeyStore
@@ -15,12 +15,12 @@ import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.ProviderException
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateException
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.NoSuchPaddingException
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
/**
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
@@ -28,7 +28,6 @@ import javax.crypto.spec.IvParameterSpec
*/
@OmitFromCoverage
class BiometricsEncryptionManagerImpl(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
) : BiometricsEncryptionManager {
private val keystore = KeyStore
@@ -51,7 +50,7 @@ class BiometricsEncryptionManagerImpl(
val secretKey: SecretKey = generateKeyOrNull()
?: run {
// user removed all biometrics from the device
destroyBiometrics(userId = userId)
settingsDiskSource.systemBiometricIntegritySource = null
return null
}
val cipher = try {
@@ -61,27 +60,37 @@ class BiometricsEncryptionManagerImpl(
} catch (_: NoSuchPaddingException) {
return null
}
// Instantiate integrity values.
createIntegrityValues(userId = userId)
// This should never fail to initialize / return false because the cipher is newly generated
cipher.initializeCipher(userId = userId, secretKey = secretKey)
initializeCipher(
userId = userId,
cipher = cipher,
secretKey = secretKey,
)
return cipher
}
override fun getOrCreateCipher(userId: String): Cipher? {
val secretKey: SecretKey = getSecretKeyOrNull()
val secretKey = getSecretKeyOrNull()
?: generateKeyOrNull()
?: run {
// user removed all biometrics from the device
destroyBiometrics(userId = userId)
settingsDiskSource.systemBiometricIntegritySource = null
return null
}
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
val isCipherInitialized = cipher.initializeCipher(userId = userId, secretKey = secretKey)
val isCipherInitialized = initializeCipher(
userId = userId,
cipher = cipher,
secretKey = secretKey,
)
return cipher?.takeIf { isCipherInitialized }
}
override fun setupBiometrics(userId: String) {
createIntegrityValues(userId)
}
override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean =
isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId)
@@ -103,7 +112,10 @@ class BiometricsEncryptionManagerImpl(
*/
private fun generateKeyOrNull(): SecretKey? {
val keyGen = try {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ENCRYPTION_KEYSTORE_NAME)
KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ENCRYPTION_KEYSTORE_NAME,
)
} catch (_: NoSuchAlgorithmException) {
return null
} catch (_: NoSuchProviderException) {
@@ -112,24 +124,40 @@ class BiometricsEncryptionManagerImpl(
return null
}
return try {
try {
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
} catch (_: InvalidAlgorithmParameterException) {
null
return null
} catch (_: ProviderException) {
null
return null
}
return getSecretKeyOrNull()
}
/**
* Returns the [SecretKey] stored in the keystore, or null if there isn't one.
*/
private fun getSecretKeyOrNull(): SecretKey? =
private fun getSecretKeyOrNull(): SecretKey? {
try {
keystore
.getKey(ENCRYPTION_KEY_NAME, null)
?.let { it as SecretKey }
keystore.load(null)
} catch (_: IllegalArgumentException) {
// keystore could not be loaded because [param] is unrecognized.
return null
} catch (_: IOException) {
// keystore data format is invalid or the password is incorrect.
return null
} catch (_: NoSuchAlgorithmException) {
// keystore integrity could not be checked due to missing algorithm.
return null
} catch (_: CertificateException) {
// keystore certificates could not be loaded
return null
}
return try {
keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey
} catch (_: KeyStoreException) {
// keystore was not loaded
null
@@ -140,31 +168,30 @@ class BiometricsEncryptionManagerImpl(
// key could not be recovered
null
}
}
/**
* Initialize a [Cipher] and return a boolean indicating whether it is valid.
*/
private fun Cipher.initializeCipher(
private fun initializeCipher(
userId: String,
cipher: Cipher,
secretKey: SecretKey,
): Boolean =
try {
authDiskSource
.getUserBiometricInitVector(userId = userId)
?.let { init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(it)) }
?: init(Cipher.ENCRYPT_MODE, secretKey)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
true
} catch (_: KeyPermanentlyInvalidatedException) {
// Biometric has changed
destroyBiometrics(userId = userId)
settingsDiskSource.systemBiometricIntegritySource = null
false
} catch (_: UnrecoverableKeyException) {
// Biometric was disabled and re-enabled
destroyBiometrics(userId = userId)
settingsDiskSource.systemBiometricIntegritySource = null
false
} catch (_: InvalidKeyException) {
// User has no key
destroyBiometrics(userId = userId)
// Fallback for old Bitwarden users without a key
createIntegrityValues(userId)
true
}
@@ -174,7 +201,11 @@ class BiometricsEncryptionManagerImpl(
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
val secretKey = getSecretKeyOrNull()
return if (cipher != null && secretKey != null) {
cipher.initializeCipher(userId = userId, secretKey = secretKey)
initializeCipher(
userId = userId,
cipher = cipher,
secretKey = secretKey,
)
} else {
false
}
@@ -184,6 +215,7 @@ class BiometricsEncryptionManagerImpl(
* Creates the initial values to be used for biometrics, including the key from which the
* master [Cipher] will be generated.
*/
@Suppress("TooGenericExceptionCaught")
private fun createIntegrityValues(userId: String) {
val systemBiometricIntegritySource = settingsDiskSource
.systemBiometricIntegritySource
@@ -194,20 +226,10 @@ class BiometricsEncryptionManagerImpl(
systemBioIntegrityState = systemBiometricIntegritySource,
value = true,
)
}
private fun destroyBiometrics(userId: String) {
settingsDiskSource.systemBiometricIntegritySource?.let { systemBioIntegrityState ->
settingsDiskSource.storeAccountBiometricIntegrityValidity(
userId = userId,
systemBioIntegrityState = systemBioIntegrityState,
value = null,
)
}
settingsDiskSource.systemBiometricIntegritySource = null
authDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = null)
keystore.deleteEntry(ENCRYPTION_KEY_NAME)
// Ignore result so biometrics function on devices that are in a state where key generation
// is not functioning
createCipherOrNull(userId)
}
}

View File

@@ -20,7 +20,10 @@ class FeatureFlagManagerImpl(
override val sdkFeatureFlags: Map<String, Boolean>
get() = mapOf(
CIPHER_KEY_ENCRYPTION_KEY to
getCipherKeyEncryptionFlagState(),
isServerVersionAtLeast(
serverConfigRepository.serverConfigStateFlow.value,
CIPHER_KEY_ENC_MIN_SERVER_VERSION,
),
)
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> =
@@ -43,16 +46,6 @@ class FeatureFlagManagerImpl(
.serverConfigStateFlow
.value
.getFlagValueOrDefault(key = key)
/**
* Get the computed value of the cipher key encryption flag based on server version and
* remote flag.
*/
private fun getCipherKeyEncryptionFlagState() =
isServerVersionAtLeast(
serverConfigRepository.serverConfigStateFlow.value,
CIPHER_KEY_ENC_MIN_SERVER_VERSION,
) && getFeatureFlag(FlagKey.CipherKeyEncryption)
}
/**

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
@@ -19,6 +20,7 @@ class NetworkConfigManagerImpl(
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
private val baseUrlInterceptors: BaseUrlInterceptors,
refreshAuthenticator: RefreshAuthenticator,
dispatcherManager: DispatcherManager,
) : NetworkConfigManager {
@@ -29,6 +31,9 @@ class NetworkConfigManagerImpl(
@Suppress("OPT_IN_USAGE")
environmentRepository
.environmentStateFlow
.onEach { environment ->
baseUrlInterceptors.environment = environment
}
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
.onEach { _ ->
// This updates the stored service configuration by performing a network request.

View File

@@ -1,27 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* Responsible for managing whether or not the app review prompt should be shown.
*/
interface ReviewPromptManager {
/**
* Register an add cipher item action.
*/
fun registerAddCipherAction()
/**
* Register a generated result action.
*/
fun registerGeneratedResultAction()
/**
* Register a create send action.
*/
fun registerCreateSendAction()
/**
* Returns a boolean value indicating whether or not the user should be prompted to
* review the app.
*/
fun shouldPromptForAppReview(): Boolean
}

View File

@@ -1,73 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
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.SettingsDiskSource
import com.x8bit.bitwarden.ui.platform.util.orZero
private const val ADD_ACTION_REQUIREMENT = 3
private const val COPY_ACTION_REQUIREMENT = 3
private const val CREATE_ACTION_REQUIREMENT = 3
/**
* Default implementation of [ReviewPromptManager].
*/
class ReviewPromptManagerImpl(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val autofillEnabledManager: AutofillEnabledManager,
private val accessibilityEnabledManager: AccessibilityEnabledManager,
) : ReviewPromptManager {
override fun registerAddCipherAction() {
authDiskSource.userState?.activeUserId ?: return
if (isMinimumAddActionsMet()) return
val currentValue = settingsDiskSource.getAddCipherActionCount().orZero()
settingsDiskSource.storeAddCipherActionCount(
count = currentValue + 1,
)
}
override fun registerGeneratedResultAction() {
authDiskSource.userState?.activeUserId ?: return
if (isMinimumCopyActionsMet()) return
val currentValue = settingsDiskSource
.getGeneratedResultActionCount()
.orZero()
settingsDiskSource.storeGeneratedResultActionCount(
count = currentValue + 1,
)
}
override fun registerCreateSendAction() {
authDiskSource.userState?.activeUserId ?: return
if (isMinimumCreateActionsMet()) return
val currentValue = settingsDiskSource.getCreateSendActionCount().orZero()
settingsDiskSource.storeCreateSendActionCount(
count = currentValue + 1,
)
}
override fun shouldPromptForAppReview(): Boolean {
authDiskSource.userState?.activeUserId ?: return false
val autofillEnabled = autofillEnabledManager.isAutofillEnabledStateFlow.value
val accessibilityEnabled = accessibilityEnabledManager.isAccessibilityEnabledStateFlow.value
val minAddActionsMet = isMinimumAddActionsMet()
val minCopyActionsMet = isMinimumCopyActionsMet()
val minCreateActionsMet = isMinimumCreateActionsMet()
return (autofillEnabled || accessibilityEnabled) &&
(minAddActionsMet || minCopyActionsMet || minCreateActionsMet)
}
private fun isMinimumAddActionsMet(): Boolean =
settingsDiskSource.getAddCipherActionCount().orZero() >= ADD_ACTION_REQUIREMENT
private fun isMinimumCopyActionsMet(): Boolean =
settingsDiskSource
.getGeneratedResultActionCount()
.orZero() >= COPY_ACTION_REQUIREMENT
private fun isMinimumCreateActionsMet(): Boolean =
settingsDiskSource.getCreateSendActionCount().orZero() >= CREATE_ACTION_REQUIREMENT
}

View File

@@ -46,10 +46,14 @@ class BitwardenClipboardManagerImpl(
},
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
val descriptor = toastDescriptorOverride
?.let { context.resources.getString(R.string.value_has_been_copied, it) }
?: context.resources.getString(R.string.copied_to_clipboard)
Toast.makeText(context, descriptor, Toast.LENGTH_SHORT).show()
val descriptor = toastDescriptorOverride ?: text
Toast
.makeText(
context,
context.resources.getString(R.string.value_has_been_copied, descriptor),
Toast.LENGTH_SHORT,
)
.show()
}
val frequency = clearClipboardFrequencySeconds ?: return

View File

@@ -6,13 +6,13 @@ import androidx.core.content.getSystemService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
@@ -40,8 +40,6 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.PushManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManagerImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
@@ -141,10 +139,8 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideBiometricsEncryptionManager(
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
)
@@ -202,6 +198,7 @@ object PlatformManagerModule {
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
baseUrlInterceptors: BaseUrlInterceptors,
refreshAuthenticator: RefreshAuthenticator,
dispatcherManager: DispatcherManager,
): NetworkConfigManager =
@@ -209,6 +206,7 @@ object PlatformManagerModule {
authRepository = authRepository,
environmentRepository = environmentRepository,
serverConfigRepository = serverConfigRepository,
baseUrlInterceptors = baseUrlInterceptors,
refreshAuthenticator = refreshAuthenticator,
dispatcherManager = dispatcherManager,
)
@@ -315,18 +313,4 @@ object PlatformManagerModule {
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
)
@Provides
@Singleton
fun provideReviewPromptManager(
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
autofillEnabledManager: AutofillEnabledManager,
accessibilityEnabledManager: AccessibilityEnabledManager,
): ReviewPromptManager = ReviewPromptManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
autofillEnabledManager = autofillEnabledManager,
accessibilityEnabledManager = accessibilityEnabledManager,
)
}

View File

@@ -3,16 +3,14 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the creation state of the app.
*/
sealed class AppCreationState {
enum class AppCreationState {
/**
* Denotes that the app is currently created.
*
* @param isAutoFill Whether the app was created for autofill.
*/
data class Created(val isAutoFill: Boolean) : AppCreationState()
CREATED,
/**
* Denotes that the app is currently destroyed.
*/
data object Destroyed : AppCreationState()
DESTROYED,
}

View File

@@ -33,12 +33,6 @@ sealed class FlagKey<out T : Any> {
ImportLoginsFlow,
SshKeyCipherItems,
VerifiedSsoDomainEndpoint,
CredentialExchangeProtocolImport,
CredentialExchangeProtocolExport,
AppReviewPrompt,
NewDevicePermanentDismiss,
NewDeviceTemporaryDismiss,
IgnoreEnvironmentCheck,
)
}
}
@@ -49,7 +43,7 @@ sealed class FlagKey<out T : Any> {
data object AuthenticatorSync : FlagKey<Boolean>() {
override val keyName: String = "enable-authenticator-sync-android"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
override val isRemotelyConfigured: Boolean = false
}
/**
@@ -96,7 +90,6 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the new verified SSO domain endpoint feature.
*/
@@ -106,72 +99,6 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding hte feature flag key for the Credential Exchange Protocol (CXP) import
* feature.
*/
data object CredentialExchangeProtocolImport : FlagKey<Boolean>() {
override val keyName: String = "cxp-import-mobile"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the Credential Exchange Protocol (CXP) export
* feature.
*/
data object CredentialExchangeProtocolExport : FlagKey<Boolean>() {
override val keyName: String = "cxp-export-mobile"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the App Review Prompt feature.
*/
data object AppReviewPrompt : FlagKey<Boolean>() {
override val keyName: String = "app-review-prompt"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the Cipher Key Encryption feature.
*/
data object CipherKeyEncryption : FlagKey<Boolean>() {
override val keyName: String = "cipher-key-encryption"
override val defaultValue: Boolean = true
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the New Device Temporary Dismiss feature.
*/
data object NewDeviceTemporaryDismiss : FlagKey<Boolean>() {
override val keyName: String = "new-device-temporary-dismiss"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key for the New Device Permanent Dismiss feature.
*/
data object NewDevicePermanentDismiss : FlagKey<Boolean>() {
override val keyName: String = "new-device-permanent-dismiss"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key to ignore an environment check.
*/
data object IgnoreEnvironmentCheck : FlagKey<Boolean>() {
override val keyName: String = "ignore-environment-check"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
//region Dummy keys for testing
/**
* Data object holding the key for a [Boolean] flag to be used in tests.
*/
@@ -199,5 +126,4 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: String = "defaultValue"
override val isRemotelyConfigured: Boolean = true
}
//endregion Dummy keys for testing
}

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
@@ -65,7 +65,7 @@ sealed class SpecialCircumstance : Parcelable {
*/
@Parcelize
data class Fido2Save(
val fido2CreateCredentialRequest: Fido2CreateCredentialRequest,
val fido2CredentialRequest: Fido2CredentialRequest,
) : SpecialCircumstance()
/**

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
@@ -27,11 +27,11 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
}
/**
* Returns [Fido2CreateCredentialRequest] when contained in the given [SpecialCircumstance].
* Returns [Fido2CredentialRequest] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toFido2CreateRequestOrNull(): Fido2CreateCredentialRequest? =
fun SpecialCircumstance.toFido2RequestOrNull(): Fido2CredentialRequest? =
when (this) {
is SpecialCircumstance.Fido2Save -> this.fido2CreateCredentialRequest
is SpecialCircumstance.Fido2Save -> this.fido2CredentialRequest
else -> null
}

View File

@@ -33,14 +33,15 @@ class EnvironmentRepositoryImpl(
environmentDiskSource.preAuthEnvironmentUrlData = value.environmentUrlData
}
override val environmentStateFlow: StateFlow<Environment> = environmentDiskSource
.preAuthEnvironmentUrlDataFlow
.map { it.toEnvironmentUrlsOrDefault() }
.stateIn(
scope = scope,
started = SharingStarted.Lazily,
initialValue = environment,
)
override val environmentStateFlow: StateFlow<Environment>
get() = environmentDiskSource
.preAuthEnvironmentUrlDataFlow
.map { it.toEnvironmentUrlsOrDefault() }
.stateIn(
scope = scope,
started = SharingStarted.Lazily,
initialValue = Environment.Us,
)
init {
authDiskSource

View File

@@ -11,7 +11,6 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.time.Instant
import javax.crypto.Cipher
/**
* Provides an API for observing and modifying settings state.
@@ -235,7 +234,7 @@ interface SettingsRepository {
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.
*/
suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult
suspend fun setupBiometricsKey(): BiometricsKeyResult
/**
* Stores the given PIN, allowing it to be used to unlock the current user's vault.
@@ -265,21 +264,4 @@ interface SettingsRepository {
* Record that a user has logged in on this device.
*/
fun storeUserHasLoggedInValue(userId: String)
/**
* Returns true if the given [userId] has previously registered for export via the credential
* exchange protocol.
*/
fun isVaultRegisteredForExport(userId: String): Boolean
/**
* Stores that the given [userId] has previously registered for export via the credential
* exchange protocol.
*/
fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean)
/**
* Gets updates for the [isVaultRegisteredForExport] value for the given [userId].
*/
fun getVaultRegisteredForExportFlow(userId: String): StateFlow<Boolean>
}

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
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.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
@@ -36,7 +37,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import javax.crypto.Cipher
private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG
@@ -50,6 +50,7 @@ class SettingsRepositoryImpl(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
accessibilityEnabledManager: AccessibilityEnabledManager,
policyManager: PolicyManager,
dispatcherManager: DispatcherManager,
@@ -481,18 +482,13 @@ class SettingsRepositoryImpl(
}
}
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
override suspend fun setupBiometricsKey(): BiometricsKeyResult {
val userId = activeUserId ?: return BiometricsKeyResult.Error
biometricsEncryptionManager.setupBiometrics(userId)
return vaultSdkSource
.getUserEncryptionKey(userId = userId)
.onSuccess { biometricsKey ->
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1),
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
.onSuccess {
authDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = it)
}
.fold(
onSuccess = { BiometricsKeyResult.Success },
@@ -502,7 +498,6 @@ class SettingsRepositoryImpl(
override fun clearBiometricsKey() {
val userId = activeUserId ?: return
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = null)
authDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
}
@@ -561,27 +556,6 @@ class SettingsRepositoryImpl(
settingsDiskSource.storeUseHasLoggedInPreviously(userId)
}
override fun isVaultRegisteredForExport(userId: String): Boolean {
return settingsDiskSource.getVaultRegisteredForExport(userId) == true
}
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean) {
settingsDiskSource.storeVaultRegisteredForExport(userId, isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): StateFlow<Boolean> {
return settingsDiskSource
.getVaultRegisteredForExportFlow(userId)
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.getVaultRegisteredForExport(userId)
?: false,
)
}
/**
* If there isn't already one generated, generate a symmetric sync key that would be used
* for communicating via IPC.

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository
@@ -91,6 +92,7 @@ object PlatformRepositoryModule {
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
vaultSdkSource: VaultSdkSource,
encryptionManager: BiometricsEncryptionManager,
accessibilityEnabledManager: AccessibilityEnabledManager,
dispatcherManager: DispatcherManager,
policyManager: PolicyManager,
@@ -101,6 +103,7 @@ object PlatformRepositoryModule {
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
vaultSdkSource = vaultSdkSource,
biometricsEncryptionManager = encryptionManager,
accessibilityEnabledManager = accessibilityEnabledManager,
dispatcherManager = dispatcherManager,
policyManager = policyManager,

View File

@@ -41,17 +41,16 @@ fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOrigin
}
return try {
val origin = getOrigin(allowList)
if (origin.isNullOrEmpty()) {
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
if (getOrigin(allowList) != null) {
Fido2ValidateOriginResult.Success
} else {
Fido2ValidateOriginResult.Success(origin)
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
}
} catch (_: IllegalStateException) {
} catch (e: IllegalStateException) {
// We know the package name is in the allow list so we can infer that this exception is
// thrown because no matching signature is found.
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
} catch (_: IllegalArgumentException) {
} catch (e: IllegalArgumentException) {
// The allow list is not formatted correctly so we notify the user passkeys are not
// supported for this application
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp

View File

@@ -12,27 +12,8 @@ inline fun <reified T> Json.decodeFromStringOrNull(
): T? =
try {
decodeFromString(string = string)
} catch (_: SerializationException) {
} catch (e: SerializationException) {
null
} catch (_: IllegalArgumentException) {
} catch (e: IllegalArgumentException) {
null
}
/**
* Attempts to decode the given JSON [string] into the given type [T]. If there is an error in
* processing the JSON or deserializing, the exception is still throw after [onFailure] lambda is
* invoked.
*/
inline fun <reified T> Json.decodeFromStringWithErrorCallback(
string: String,
onFailure: (throwable: Throwable) -> Unit,
): T =
try {
decodeFromString(string = string)
} catch (se: SerializationException) {
onFailure(se)
throw se
} catch (iae: IllegalArgumentException) {
onFailure(iae)
throw iae
}

View File

@@ -7,7 +7,7 @@ import com.bitwarden.generators.PasswordGeneratorRequest
import com.bitwarden.generators.UsernameGeneratorRequest
import com.bitwarden.vault.PasswordHistoryView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
@@ -54,7 +54,7 @@ class GeneratorRepositoryImpl(
private val authDiskSource: AuthDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val passwordHistoryDiskSource: PasswordHistoryDiskSource,
private val reviewPromptManager: ReviewPromptManager,
private val policyManager: PolicyManager,
dispatcherManager: DispatcherManager,
) : GeneratorRepository {
@@ -69,9 +69,7 @@ class GeneratorRepositoryImpl(
get() = mutablePasswordHistoryStateFlow.asStateFlow()
override val generatorResultFlow: Flow<GeneratorResult>
get() = generatorResultChannel
.receiveAsFlow()
.onEach { reviewPromptManager.registerGeneratedResultAction() }
get() = generatorResultChannel.receiveAsFlow()
init {
mutablePasswordHistoryStateFlow

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.tools.generator.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
@@ -33,7 +33,7 @@ object GeneratorRepositoryModule {
vaultSdkSource: VaultSdkSource,
passwordHistoryDiskSource: PasswordHistoryDiskSource,
dispatcherManager: DispatcherManager,
reviewPromptManager: ReviewPromptManager,
policyManager: PolicyManager,
): GeneratorRepository = GeneratorRepositoryImpl(
clock = clock,
generatorSdkSource = generatorSdkSource,
@@ -42,6 +42,6 @@ object GeneratorRepositoryModule {
vaultSdkSource = vaultSdkSource,
passwordHistoryDiskSource = passwordHistoryDiskSource,
dispatcherManager = dispatcherManager,
reviewPromptManager = reviewPromptManager,
policyManager = policyManager,
)
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.vault.datasource.disk
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.decodeFromStringWithErrorCallback
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -25,7 +24,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber
/**
* Default implementation of [VaultDiskSource].
@@ -72,9 +70,9 @@ class VaultDiskSourceImpl(
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Cipher>(
json.decodeFromString<SyncResponseJson.Cipher>(
string = entity.cipherJson,
) { Timber.e(it, "Failed to deserialize Cipher in Vault") }
)
}
}
.awaitAll()
@@ -128,11 +126,7 @@ class VaultDiskSourceImpl(
.getDomains(userId)
.map { entity ->
withContext(dispatcherManager.default) {
entity?.domainsJson?.let { domains ->
json.decodeFromStringWithErrorCallback<SyncResponseJson.Domains>(
string = domains,
) { Timber.e(it, "Failed to deserialize Domains in Vault") }
}
entity?.domainsJson?.let { json.decodeFromString<SyncResponseJson.Domains>(it) }
}
}
@@ -198,9 +192,7 @@ class VaultDiskSourceImpl(
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Send>(
string = entity.sendJson,
) { Timber.e(it, "Failed to deserialize Send in Vault") }
json.decodeFromString<SyncResponseJson.Send>(entity.sendJson)
}
}
.awaitAll()

View File

@@ -5,7 +5,6 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@@ -150,9 +149,4 @@ interface CiphersApi {
*/
@GET("ciphers/has-unassigned-ciphers")
suspend fun hasUnassignedCiphers(): NetworkResult<Boolean>
@POST("ciphers/import")
suspend fun importCiphers(
@Body body: ImportCiphersJsonRequest,
): NetworkResult<Unit>
}

View File

@@ -1,37 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents an import ciphers request.
*
* @property folders A list of folders to import.
* @property ciphers A list of ciphers to import.
* @property folderRelationships A map of cipher folder relationships to import. Key correlates to
* the index of the cipher in the ciphers list. Value correlates to the index of the folder in the
* folders list.
*/
@Serializable
data class ImportCiphersJsonRequest(
@SerialName("folders")
val folders: List<FolderWithIdJsonRequest>,
@SerialName("ciphers")
val ciphers: List<CipherJsonRequest>,
@SerialName("folderRelationships")
val folderRelationships: Map<Int, Int>,
) {
/**
* Represents a folder request with an optional [id] if the folder already exists.
*
* @property name The name of the folder.
* @property id The ID of the folder, if it already exists. Null otherwise.
**/
@Serializable
data class FolderWithIdJsonRequest(
@SerialName("name")
val name: String?,
@SerialName("id")
val id: String?,
)
}

View File

@@ -1,41 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The response body for importing ciphers.
*/
@Serializable
sealed class ImportCiphersResponseJson {
/**
* Models a successful json response.
*/
@Serializable
object Success : ImportCiphersResponseJson()
/**
* Represents the json body of an invalid request.
*
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : ImportCiphersResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -153,7 +153,6 @@ data class SyncResponseJson(
* @property key The key of the profile (nullable).
* @property securityStamp The secure stamp of the profile (nullable).
* @property providers A list of providers associated with the profile (nullable).
* @property creationDate The creation date of the account.
*/
@Serializable
data class Profile(
@@ -210,10 +209,6 @@ data class SyncResponseJson(
@SerialName("providers")
val providers: List<Provider>?,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime,
) {
/**
* Represents an organization in the vault response.

View File

@@ -5,8 +5,6 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@@ -120,9 +118,4 @@ interface CiphersService {
* Returns a boolean indicating if the active user has unassigned ciphers.
*/
suspend fun hasUnassignedCiphers(): Result<Boolean>
/**
* Attempt to import ciphers.
*/
suspend fun importCiphers(request: ImportCiphersJsonRequest): Result<ImportCiphersResponseJson>
}

View File

@@ -13,8 +13,6 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRes
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@@ -218,23 +216,6 @@ class CiphersServiceImpl(
.hasUnassignedCiphers()
.toResult()
override suspend fun importCiphers(
request: ImportCiphersJsonRequest,
): Result<ImportCiphersResponseJson> =
ciphersApi
.importCiphers(body = request)
.toResult()
.map { ImportCiphersResponseJson.Success }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<ImportCiphersResponseJson.Invalid>(
code = 400,
json = json,
)
?: throw throwable
}
private fun createMultipartBodyBuilder(
encryptedFile: File,
filename: String?,

View File

@@ -94,15 +94,6 @@ interface VaultSdkSource {
encryptedPin: String,
): Result<String>
/**
* Validate the user pin using the [pinProtectedUserKey].
*/
suspend fun validatePin(
userId: String,
pin: String,
pinProtectedUserKey: String,
): Result<Boolean>
/**
* Gets the key for an auth request that is required to approve or decline it.
*/

View File

@@ -109,17 +109,6 @@ class VaultSdkSourceImpl(
.derivePinUserKey(encryptedPin = encryptedPin)
}
override suspend fun validatePin(
userId: String,
pin: String,
pinProtectedUserKey: String,
): Result<Boolean> =
runCatchingWithLogs {
getClient(userId = userId)
.auth()
.validatePin(pin = pin, pinProtectedUserKey = pinProtectedUserKey)
}
override suspend fun getAuthRequestKey(
publicKey: String,
userId: String,

View File

@@ -6,7 +6,6 @@ import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
@@ -36,7 +35,7 @@ import java.time.Clock
/**
* The default implementation of the [CipherManager].
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions")
class CipherManagerImpl(
private val fileManager: FileManager,
private val authDiskSource: AuthDiskSource,
@@ -44,7 +43,6 @@ class CipherManagerImpl(
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val clock: Clock,
private val reviewPromptManager: ReviewPromptManager,
) : CipherManager {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@@ -59,10 +57,7 @@ class CipherManagerImpl(
.onSuccess { vaultDiskSource.saveCipher(userId = userId, cipher = it) }
.fold(
onFailure = { CreateCipherResult.Error },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
CreateCipherResult.Success
},
onSuccess = { CreateCipherResult.Success },
)
}
@@ -92,10 +87,7 @@ class CipherManagerImpl(
}
.fold(
onFailure = { CreateCipherResult.Error },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
CreateCipherResult.Success
},
onSuccess = { CreateCipherResult.Success },
)
}

View File

@@ -122,7 +122,6 @@ class TotpCodeManagerImpl(
CipherRepromptType.NONE -> false
},
orgUsesTotp = cipher.organizationUseTotp,
orgId = cipher.organizationId,
)
}
.onFailure {

View File

@@ -1,9 +1,5 @@
package com.x8bit.bitwarden.data.vault.manager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
@@ -54,8 +50,6 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
/**
@@ -68,7 +62,6 @@ private const val MAXIMUM_INVALID_UNLOCK_ATTEMPTS = 5
*/
@Suppress("TooManyFunctions", "LongParameterList")
class VaultLockManagerImpl(
private val clock: Clock,
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
@@ -77,15 +70,13 @@ class VaultLockManagerImpl(
private val userLogoutManager: UserLogoutManager,
private val trustedDeviceManager: TrustedDeviceManager,
dispatcherManager: DispatcherManager,
context: Context,
) : VaultLockManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
/**
* This [Map] tracks all active timeout [Job]s that are running and their associated data using
* the user ID as the key.
* This [Map] tracks all active timeout [Job]s that are running using the user ID as the key.
*/
private val userIdTimerJobMap: MutableMap<String, TimeoutJobData> = ConcurrentHashMap()
private val userIdTimerJobMap = mutableMapOf<String, Job>()
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@@ -105,10 +96,6 @@ class VaultLockManagerImpl(
observeUserSwitchingChanges()
observeVaultTimeoutChanges()
observeUserLogoutResults()
context.registerReceiver(
ScreenStateBroadcastReceiver(),
IntentFilter(Intent.ACTION_SCREEN_ON),
)
}
override fun isVaultUnlocked(userId: String): Boolean =
@@ -318,40 +305,29 @@ class VaultLockManagerImpl(
}
private fun observeAppCreationChanges() {
var isFirstCreated = true
appStateManager
.appCreatedStateFlow
.onEach { appCreationState ->
when (appCreationState) {
is AppCreationState.Created -> {
handleOnCreated(
createdForAutofill = appCreationState.isAutoFill,
isFirstCreated = isFirstCreated,
)
isFirstCreated = false
}
AppCreationState.Destroyed -> Unit
AppCreationState.CREATED -> Unit
AppCreationState.DESTROYED -> handleOnDestroyed()
}
}
.launchIn(unconfinedScope)
}
private fun handleOnCreated(
createdForAutofill: Boolean,
isFirstCreated: Boolean,
) {
val userId = activeUserId ?: return
checkForVaultTimeout(
userId = userId,
checkTimeoutReason = CheckTimeoutReason.AppCreated(
firstTimeCreation = isFirstCreated,
createdForAutofill = createdForAutofill,
),
)
private fun handleOnDestroyed() {
activeUserId?.let { userId ->
checkForVaultTimeout(
userId = userId,
checkTimeoutReason = CheckTimeoutReason.APP_RESTARTED,
)
}
}
private fun observeAppForegroundChanges() {
var isFirstForeground = true
appStateManager
.appForegroundStateFlow
.onEach { appForegroundState ->
@@ -360,7 +336,10 @@ class VaultLockManagerImpl(
handleOnBackground()
}
AppForegroundState.FOREGROUNDED -> handleOnForeground()
AppForegroundState.FOREGROUNDED -> {
handleOnForeground(isFirstForeground = isFirstForeground)
isFirstForeground = false
}
}
}
.launchIn(unconfinedScope)
@@ -370,13 +349,19 @@ class VaultLockManagerImpl(
val userId = activeUserId ?: return
checkForVaultTimeout(
userId = userId,
checkTimeoutReason = CheckTimeoutReason.AppBackgrounded,
checkTimeoutReason = CheckTimeoutReason.APP_BACKGROUNDED,
)
}
private fun handleOnForeground() {
private fun handleOnForeground(isFirstForeground: Boolean) {
val userId = activeUserId ?: return
userIdTimerJobMap.remove(key = userId)?.job?.cancel()
userIdTimerJobMap[userId]?.cancel()
if (isFirstForeground) {
checkForVaultTimeout(
userId = userId,
checkTimeoutReason = CheckTimeoutReason.APP_RESTARTED,
)
}
}
private fun observeUserSwitchingChanges() {
@@ -472,11 +457,11 @@ class VaultLockManagerImpl(
currentActiveUserId: String,
) {
// Make sure to clear the now-active user's timeout job.
userIdTimerJobMap.remove(key = currentActiveUserId)?.job?.cancel()
userIdTimerJobMap[currentActiveUserId]?.cancel()
// Check if the user's timeout action should be performed as we switch away.
checkForVaultTimeout(
userId = previousActiveUserId,
checkTimeoutReason = CheckTimeoutReason.UserChanged,
checkTimeoutReason = CheckTimeoutReason.USER_CHANGED,
)
}
@@ -506,19 +491,10 @@ class VaultLockManagerImpl(
VaultTimeout.OnAppRestart -> {
// If this is an app restart, trigger the timeout action; otherwise ignore.
if (checkTimeoutReason is CheckTimeoutReason.AppCreated) {
// We need to check the timeout action on the first time creation no matter what
// for all subsequent creations we should check if this is for autofill and
// and if it is we skip checking the timeout action.
if (
checkTimeoutReason.firstTimeCreation ||
!checkTimeoutReason.createdForAutofill
) {
handleTimeoutAction(
userId = userId,
vaultTimeoutAction = vaultTimeoutAction,
)
}
if (checkTimeoutReason == CheckTimeoutReason.APP_RESTARTED) {
// On restart the vault should be locked already but we may need to soft-logout
// the user.
handleTimeoutAction(userId = userId, vaultTimeoutAction = vaultTimeoutAction)
}
}
@@ -526,23 +502,21 @@ class VaultLockManagerImpl(
when (checkTimeoutReason) {
// Always preform the timeout action on app restart to ensure the user is
// in the correct state.
is CheckTimeoutReason.AppCreated -> {
if (checkTimeoutReason.firstTimeCreation) {
handleTimeoutAction(
userId = userId,
vaultTimeoutAction = vaultTimeoutAction,
)
}
CheckTimeoutReason.APP_RESTARTED -> {
handleTimeoutAction(
userId = userId,
vaultTimeoutAction = vaultTimeoutAction,
)
}
// User no longer active or engaging with the app.
CheckTimeoutReason.AppBackgrounded,
CheckTimeoutReason.UserChanged,
CheckTimeoutReason.APP_BACKGROUNDED,
CheckTimeoutReason.USER_CHANGED,
-> {
handleTimeoutActionWithDelay(
userId = userId,
vaultTimeoutAction = vaultTimeoutAction,
delayMs = vaultTimeout
delayInMs = vaultTimeout
.vaultTimeoutInMinutes
?.minutes
?.inWholeMilliseconds
@@ -555,26 +529,20 @@ class VaultLockManagerImpl(
}
/**
* Performs the [VaultTimeoutAction] for the given [userId] after the [delayMs] has passed.
* Performs the [VaultTimeoutAction] for the given [userId] after the [delayInMs] has passed.
*
* @see handleTimeoutAction
*/
private fun handleTimeoutActionWithDelay(
userId: String,
vaultTimeoutAction: VaultTimeoutAction,
delayMs: Long,
delayInMs: Long,
) {
userIdTimerJobMap.remove(key = userId)?.job?.cancel()
userIdTimerJobMap[userId] = TimeoutJobData(
job = unconfinedScope.launch {
delay(timeMillis = delayMs)
userIdTimerJobMap.remove(key = userId)
handleTimeoutAction(userId = userId, vaultTimeoutAction = vaultTimeoutAction)
},
vaultTimeoutAction = vaultTimeoutAction,
startTimeMs = clock.millis(),
durationMs = delayMs,
)
userIdTimerJobMap[userId]?.cancel()
userIdTimerJobMap[userId] = unconfinedScope.launch {
delay(timeMillis = delayInMs)
handleTimeoutAction(userId = userId, vaultTimeoutAction = vaultTimeoutAction)
}
}
/**
@@ -621,60 +589,11 @@ class VaultLockManagerImpl(
}
/**
* A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
* vault timeout jobs to ensure they complete at the correct time.
*
* This is necessary because the [delay] function in a coroutine will not keep accurate time
* when the screen is off. We do not cancel the job when the screen is off, this allows the
* job to complete as-soon-as possible if the screen is powered off for an extended period.
* Helper enum that indicates the reason we are checking for timeout.
*/
private inner class ScreenStateBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
userIdTimerJobMap.map { (userId, data) ->
handleTimeoutActionWithDelay(
userId = userId,
vaultTimeoutAction = data.vaultTimeoutAction,
delayMs = data.durationMs - (clock.millis() - data.startTimeMs)
.coerceAtLeast(minimumValue = 0L),
)
}
}
}
/**
* A wrapper class containing all relevant data concerning a timeout action [Job].
*/
private data class TimeoutJobData(
val job: Job,
val vaultTimeoutAction: VaultTimeoutAction,
val startTimeMs: Long,
val durationMs: Long,
)
/**
* Helper sealed class which denotes the reason to check the vault timeout.
*/
private sealed class CheckTimeoutReason {
/**
* Indicates the app has been backgrounded but is still running.
*/
data object AppBackgrounded : CheckTimeoutReason()
/**
* Indicates the app has entered a Created state.
*
* @param firstTimeCreation if this is the first time the process is being created.
* @param createdForAutofill if the the creation event is due to an activity being launched
* for autofill.
*/
data class AppCreated(
val firstTimeCreation: Boolean,
val createdForAutofill: Boolean,
) : CheckTimeoutReason()
/**
* Indicates that the current user has changed.
*/
data object UserChanged : CheckTimeoutReason()
private enum class CheckTimeoutReason {
APP_BACKGROUNDED,
APP_RESTARTED,
USER_CHANGED,
}
}

View File

@@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -45,7 +44,6 @@ object VaultManagerModule {
authDiskSource: AuthDiskSource,
fileManager: FileManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
): CipherManager = CipherManagerImpl(
fileManager = fileManager,
authDiskSource = authDiskSource,
@@ -53,7 +51,6 @@ object VaultManagerModule {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
clock = clock,
reviewPromptManager = reviewPromptManager,
)
@Provides
@@ -71,8 +68,6 @@ object VaultManagerModule {
@Provides
@Singleton
fun provideVaultLockManager(
@ApplicationContext context: Context,
clock: Clock,
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
@@ -83,8 +78,6 @@ object VaultManagerModule {
trustedDeviceManager: TrustedDeviceManager,
): VaultLockManager =
VaultLockManagerImpl(
context = context,
clock = clock,
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,

View File

@@ -29,5 +29,4 @@ data class VerificationCodeItem(
val username: String?,
val hasPasswordReprompt: Boolean,
val orgUsesTotp: Boolean,
val orgId: String?,
)

View File

@@ -33,7 +33,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import javax.crypto.Cipher
/**
* Responsible for managing vault data inside the network layer.
@@ -190,7 +189,7 @@ interface VaultRepository : CipherManager, VaultLockManager {
/**
* Attempt to unlock the vault using the stored biometric key for the currently active user.
*/
suspend fun unlockVaultWithBiometrics(cipher: Cipher): VaultUnlockResult
suspend fun unlockVaultWithBiometrics(): VaultUnlockResult
/**
* Attempt to unlock the vault with the given [masterPassword] and for the currently active

View File

@@ -23,7 +23,6 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
@@ -116,7 +115,6 @@ import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.time.Clock
import java.time.temporal.ChronoUnit
import javax.crypto.Cipher
/**
* A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the
@@ -146,7 +144,6 @@ class VaultRepositoryImpl(
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
private val reviewPromptManager: ReviewPromptManager,
) : VaultRepository,
CipherManager by cipherManager,
VaultLockManager by vaultLockManager {
@@ -543,36 +540,19 @@ class VaultRepositoryImpl(
),
)
override suspend fun unlockVaultWithBiometrics(cipher: Cipher): VaultUnlockResult {
override suspend fun unlockVaultWithBiometrics(): VaultUnlockResult {
val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError
val biometricsKey = authDiskSource
.getUserBiometricUnlockKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
val iv = authDiskSource.getUserBiometricInitVector(userId = userId)
return this
.unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = iv
?.let {
cipher
.doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1))
.decodeToString()
}
?: biometricsKey,
),
)
return unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
)
.also {
if (it is VaultUnlockResult.Success) {
if (iv == null) {
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1),
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
deriveTemporaryPinProtectedUserKeyIfNecessary(userId = userId)
}
}
@@ -652,10 +632,7 @@ class VaultRepositoryImpl(
}
.fold(
onFailure = { CreateSendResult.Error(message = null) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(it)
},
onSuccess = { CreateSendResult.Success(it) },
)
}
@@ -1375,11 +1352,11 @@ class VaultRepositoryImpl(
if (serverRevisionDate < lastSyncTimeMs) {
// We can skip the actual sync call if there is no new data or
// database scheme changes since the last sync.
vaultDiskSource.resyncVaultData(userId = userId)
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
vaultDiskSource.resyncVaultData(userId = userId)
val itemsAvailable = vaultDiskSource
.getCiphers(userId)
.firstOrNull()

View File

@@ -5,7 +5,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
@@ -53,7 +52,6 @@ object VaultRepositoryModule {
userLogoutManager: UserLogoutManager,
databaseSchemeManager: DatabaseSchemeManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
): VaultRepository = VaultRepositoryImpl(
syncService = syncService,
sendsService = sendsService,
@@ -72,6 +70,5 @@ object VaultRepositoryModule {
userLogoutManager = userLogoutManager,
databaseSchemeManager = databaseSchemeManager,
clock = clock,
reviewPromptManager = reviewPromptManager,
)
}

View File

@@ -34,11 +34,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers.rememberSetupAutoFillHandler
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.image.BitwardenGifImage
@@ -78,8 +80,10 @@ fun SetupAutoFillScreen(
when (state.dialogState) {
is SetupAutoFillDialogState.AutoFillFallbackDialog -> {
BitwardenBasicDialog(
title = null,
message = stringResource(id = R.string.bitwarden_autofill_go_to_settings),
visibilityState = BasicDialogState.Shown(
title = null,
message = R.string.bitwarden_autofill_go_to_settings.asText(),
),
onDismissRequest = handler.onDismissDialog,
)
}

View File

@@ -43,9 +43,11 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
@@ -75,7 +77,7 @@ fun SetupUnlockScreen(
showBiometricsPrompt = true
biometricsManager.promptBiometrics(
onSuccess = {
handler.unlockWithBiometricToggle(it)
handler.unlockWithBiometricToggle()
showBiometricsPrompt = false
},
onCancel = { showBiometricsPrompt = false },
@@ -322,12 +324,14 @@ private fun SetupUnlockScreenDialogs(
) {
when (dialogState) {
is SetupUnlockState.DialogState.Loading -> BitwardenLoadingDialog(
text = dialogState.title(),
visibilityState = LoadingDialogState.Shown(text = dialogState.title),
)
is SetupUnlockState.DialogState.Error -> BitwardenBasicDialog(
title = dialogState.title(),
message = dialogState.message(),
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissRequest,
)

View File

@@ -65,12 +65,8 @@ class SetupUnlockViewModel @Inject constructor(
SetupUnlockAction.EnableBiometricsClick -> handleEnableBiometricsClick()
SetupUnlockAction.SetUpLaterClick -> handleSetUpLaterClick()
SetupUnlockAction.DismissDialog -> handleDismissDialog()
SetupUnlockAction.UnlockWithBiometricToggleDisabled -> {
handleUnlockWithBiometricToggleDisabled()
}
is SetupUnlockAction.UnlockWithBiometricToggleEnabled -> {
handleUnlockWithBiometricToggleEnabled(action)
is SetupUnlockAction.UnlockWithBiometricToggle -> {
handleUnlockWithBiometricToggle(action)
}
is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
@@ -131,23 +127,23 @@ class SetupUnlockViewModel @Inject constructor(
}
}
private fun handleUnlockWithBiometricToggleDisabled() {
settingsRepository.clearBiometricsKey()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
}
private fun handleUnlockWithBiometricToggleEnabled(
action: SetupUnlockAction.UnlockWithBiometricToggleEnabled,
private fun handleUnlockWithBiometricToggle(
action: SetupUnlockAction.UnlockWithBiometricToggle,
) {
mutableStateFlow.update {
it.copy(
dialogState = SetupUnlockState.DialogState.Loading(R.string.saving.asText()),
isUnlockWithBiometricsEnabled = true,
)
}
viewModelScope.launch {
val result = settingsRepository.setupBiometricsKey(cipher = action.cipher)
sendAction(SetupUnlockAction.Internal.BiometricsKeyResultReceive(result = result))
if (action.isEnabled) {
mutableStateFlow.update {
it.copy(
dialogState = SetupUnlockState.DialogState.Loading(R.string.saving.asText()),
isUnlockWithBiometricsEnabled = true,
)
}
viewModelScope.launch {
val result = settingsRepository.setupBiometricsKey()
sendAction(SetupUnlockAction.Internal.BiometricsKeyResultReceive(result))
}
} else {
settingsRepository.clearBiometricsKey()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
}
}
@@ -276,15 +272,10 @@ sealed class SetupUnlockEvent {
*/
sealed class SetupUnlockAction {
/**
* User toggled the unlock with biometrics switch to off.
* User toggled the unlock with biometrics switch.
*/
data object UnlockWithBiometricToggleDisabled : SetupUnlockAction()
/**
* User toggled the unlock with biometrics switch to on.
*/
data class UnlockWithBiometricToggleEnabled(
val cipher: Cipher,
data class UnlockWithBiometricToggle(
val isEnabled: Boolean,
) : SetupUnlockAction()
/**

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockAction
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockViewModel
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import javax.crypto.Cipher
/**
* A collection of handler functions for managing actions within the context of the Setup Unlock
@@ -15,7 +14,7 @@ data class SetupUnlockHandler(
val onUnlockWithPinToggle: (UnlockWithPinState) -> Unit,
val onContinueClick: () -> Unit,
val onSetUpLaterClick: () -> Unit,
val unlockWithBiometricToggle: (cipher: Cipher) -> Unit,
val unlockWithBiometricToggle: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -26,7 +25,9 @@ data class SetupUnlockHandler(
fun create(viewModel: SetupUnlockViewModel): SetupUnlockHandler =
SetupUnlockHandler(
onDisableBiometrics = {
viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggleDisabled)
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false),
)
},
onEnableBiometrics = {
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
@@ -38,7 +39,7 @@ data class SetupUnlockHandler(
onSetUpLaterClick = { viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) },
unlockWithBiometricToggle = {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggleEnabled(cipher = it),
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true),
)
},
)

View File

@@ -35,9 +35,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmailHandler
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
@@ -150,13 +152,18 @@ private fun CheckEmailContent(
)
Spacer(modifier = Modifier.height(8.dp))
val descriptionAnnotatedString = R.string.we_sent_an_email_to.toAnnotatedString(
args = arrayOf(email),
emphasisHighlightStyle = SpanStyle(
val descriptionAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.we_sent_an_email_to,
email,
),
highlights = listOf(email),
highlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag = "EMAIL",
)
Text(
text = descriptionAnnotatedString,
@@ -234,14 +241,18 @@ private fun CheckEmailLegacyContent(
Spacer(modifier = Modifier.height(16.dp))
@Suppress("MaxLineLength")
val descriptionAnnotatedString =
R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account.toAnnotatedString(
val descriptionAnnotatedString = createAnnotatedString(
mainString = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
email,
emphasisHighlightStyle = SpanStyle(
),
highlights = listOf(email),
highlightStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag = "EMAIL",
)
Text(
text = descriptionAnnotatedString,
@@ -265,17 +276,34 @@ private fun CheckEmailLegacyContent(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val goBackAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.no_email_go_back_to_edit_your_email_address,
),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.go_back),
onTextClick = onChangeEmailClick,
),
),
)
Text(
text = R.string.no_email_go_back_to_edit_your_email_address.toAnnotatedString {
onChangeEmailClick()
},
text = goBackAnnotatedString,
)
Spacer(modifier = Modifier.height(32.dp))
val logInAnnotatedString = createClickableAnnotatedString(
mainString = stringResource(
id = R.string.or_log_in_you_may_already_have_an_account,
),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(id = R.string.log_in),
onTextClick = onLoginClick,
),
),
)
Text(
text = R.string.or_log_in_you_may_already_have_an_account
.toAnnotatedString {
onLoginClick()
},
text = logInAnnotatedString,
)
}
}

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