Compare commits

..

5 Commits

Author SHA1 Message Date
David Perez
277fcbf14c 🍒 Add Biometric logging (#5645) 2025-08-05 12:22:52 -05:00
Patrick Honkonen
729ec60ba8 🍒[PM-24206] Fix filtered verification code search (#5622) 2025-07-30 17:18:30 +00:00
Patrick Honkonen
3d220cf765 🍒 [PM-24205] Fix Fido2CredentialStore to save new credentials correctly (#5604) 2025-07-28 20:35:06 +00:00
Patrick Honkonen
df2acadea0 🍒 [PM-24204] Correct TOTP generation to use cipherId instead of totpCode (#5603) 2025-07-28 20:32:35 +00:00
David Perez
7043b4be26 🍒 Fix crash in Android 13 (#5591) 2025-07-25 14:31:24 -05:00
831 changed files with 86463 additions and 17925 deletions

View File

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

View File

@@ -1,49 +0,0 @@
name: 'Setup Android Build'
description: 'Setup Android build environment with Gradle, Ruby, and Fastlane'
inputs:
java-version:
description: 'Java version to use'
required: false
default: '17'
runs:
using: 'composite'
steps:
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: "temurin"
java-version: ${{ inputs.java-version }}
- name: Install Fastlane
shell: bash
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- release/**/*
workflow_dispatch:
inputs:
version-name:
@@ -51,13 +50,13 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -67,7 +66,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -82,7 +81,7 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -110,10 +109,10 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -189,10 +188,10 @@ jobs:
json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -202,7 +201,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -216,25 +215,16 @@ jobs:
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=$GITHUB_RUN_NUMBER
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
bundle exec fastlane setBuildVersionInfo \
bundle exec fastlane setAuthenticatorBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
regex='versionName = "([^"]+)"'
if [[ "$(cat authenticator/build.gradle.kts)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- release/**/*
workflow_dispatch:
inputs:
version-name:
@@ -18,12 +17,12 @@ on:
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
default: true
default: false
type: boolean
publish-to-play-store:
description: "Optional. Deploy bundle artifact to Google Play Store"
required: false
default: true
default: false
type: boolean
env:
@@ -52,13 +51,13 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -68,7 +67,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -83,7 +82,7 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -118,10 +117,10 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -184,10 +183,10 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -197,7 +196,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -417,7 +416,7 @@ jobs:
bundle exec fastlane run validate_play_store_json_key
- name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.ref_name == 'main') }}
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
@@ -429,10 +428,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -481,10 +480,10 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -494,7 +493,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -526,8 +525,8 @@ jobs:
versionCode:$VERSION_CODE \
versionName:${{ inputs.version-name || '' }}
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
regex='versionName = "([^"]+)"'
if [[ "$(cat app/build.gradle.kts)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY

View File

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

View File

@@ -8,15 +8,26 @@ on:
jobs:
crowdin-sync:
name: Crowdin Pull - ${{ github.event_name }}
name: Crowdin Pull - ${{ matrix.name }} - ${{ github.event_name }}
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
id-token: write
strategy:
matrix:
include:
- name: Password Manager
project_id: 269690
config: crowdin-bwpm.yml
branch: crowdin-pull-bwpm
- name: Authenticator
project_id: 673718
config: crowdin-bwa.yml
branch: crowdin-pull-bwa
steps:
- name: Checkout repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -43,29 +54,29 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Download translations
uses: crowdin/github-action@590c05e09a29f392b203faf4d6aa8e0cd32c7835 # v2.9.1
- name: Download translations for ${{ matrix.name }}
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "269690"
_CROWDIN_PROJECT_ID: ${{ matrix.project_id }}
with:
config: crowdin.yml
config: ${{ matrix.config }}
upload_sources: false
upload_translations: false
download_translations: true
github_user_name: "bitwarden-devops-bot"
github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
commit_message: "Crowdin Pull"
localization_branch_name: "crowdin-pull"
commit_message: "Crowdin Pull - ${{ matrix.name }}"
localization_branch_name: ${{ matrix.branch }}
create_pull_request: true
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
pull_request_title: "Crowdin Pull - ${{ matrix.name }}"
pull_request_body: ":inbox_tray: New translations for ${{ matrix.name }} received!"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -32,16 +32,27 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@590c05e09a29f392b203faf4d6aa8e0cd32c7835 # v2.9.1
- name: Upload sources for Password Manager
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: crowdin.yml
config: crowdin-bwpm.yml
upload_sources: true
upload_translations: false
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload sources for Authenticator
uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2 # v2.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "673718"
with:
config: crowdin-bwa.yml
upload_sources: true
upload_translations: false

View File

@@ -25,15 +25,10 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
with:
inputs: ${{ toJson(inputs) }}
- name: Get branch from workflow run
id: get_release_branch
env:
@@ -50,29 +45,23 @@ jobs:
exit 1
fi
echo "🔖 Release branch: $release_branch"
echo "🔖 Workflow name: $workflow_name"
echo "release_branch=$release_branch" >> $GITHUB_OUTPUT
echo "workflow_name=$workflow_name" >> $GITHUB_OUTPUT
case "$workflow_name" in
*"Password Manager"* | "Build")
app_name="Password Manager"
app_name_suffix="bwpm"
echo "app_name=Password Manager" >> $GITHUB_OUTPUT
echo "app_name_suffix=bwpm" >> $GITHUB_OUTPUT
;;
*"Authenticator"*)
app_name="Authenticator"
app_name_suffix="bwa"
echo "app_name=Authenticator" >> $GITHUB_OUTPUT
echo "app_name_suffix=bwa" >> $GITHUB_OUTPUT
;;
*)
echo "::error::Unknown workflow name: $workflow_name"
exit 1
;;
esac
echo "🔖 App name: $app_name"
echo "🔖 App name suffix: $app_name_suffix"
echo "app_name=$app_name" >> $GITHUB_OUTPUT
echo "app_name_suffix=$app_name_suffix" >> $GITHUB_OUTPUT
- name: Get version info from run logs and set release tag name
id: get_release_info
@@ -110,7 +99,7 @@ jobs:
echo "🔖 New tag name: $tag_name"
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
last_release_tag=$(git tag -l --sort=-authordate | grep "$APP_NAME_SUFFIX" | head -n 1)
echo "🔖 Last release tag: $last_release_tag"
echo "last_release_tag=$last_release_tag" >> $GITHUB_OUTPUT
@@ -127,34 +116,6 @@ jobs:
find $ARTIFACTS_PATH -type f
fi
# Files that won't be included in any release
files_to_remove=(
"com.x8bit.bitwarden.aab"
"com.x8bit.bitwarden.aab-sha256.txt"
"com.x8bit.bitwarden.beta.apk"
"com.x8bit.bitwarden.beta.apk-sha256.txt"
"com.x8bit.bitwarden.beta.aab"
"com.x8bit.bitwarden.beta.aab-sha256.txt"
"com.x8bit.bitwarden.beta-fdroid.apk"
"com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt"
"com.x8bit.bitwarden.dev.apk"
"com.x8bit.bitwarden.dev.apk-sha256.txt"
"com.bitwarden.authenticator.aab"
"authenticator-android-aab-sha256.txt"
)
for file in "${files_to_remove[@]}"; do
find $ARTIFACTS_PATH -name "$file" -type f -delete
done
echo "🔖 Removed internal artifacts."
echo ""
echo "🔖 Files to be included in the release:"
find $ARTIFACTS_PATH -type f
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -206,28 +167,23 @@ jobs:
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
run: |
is_latest_release=false
if [[ "$_APP_NAME" == "Password Manager" ]]; then
is_latest_release=true
fi
echo "⌛️ Creating release for $_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER) on $_TARGET_COMMIT"
release_url=$(gh release create "$_TAG_NAME" \
--title "$_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER)" \
--target "$_TARGET_COMMIT" \
--generate-notes \
--notes-start-tag "$_LAST_RELEASE_TAG" \
--latest=$is_latest_release \
--draft \
$ARTIFACTS_PATH/*/*)
# Extract release tag from URL
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
echo "release_id_from_url=$release_id_from_url" >> $GITHUB_OUTPUT
echo "url=$release_url" >> $GITHUB_OUTPUT
echo "✅ Release created: $release_url"
echo "🔖 Release ID from URL: $release_id_from_url"
# Get release info for outputs
release_data=$(gh release view "$_TAG_NAME" --json id)
release_id=$(echo "$release_data" | jq -r .id)
echo "id=$release_id" >> $GITHUB_OUTPUT
echo "url=$release_url" >> $GITHUB_OUTPUT
- name: Update Release Description
id: update_release_description
@@ -235,10 +191,10 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_RELEASE_ID: ${{ steps.create_release.outputs.release_id_from_url }}
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
run: |
echo "Getting current release body. Release ID: $_RELEASE_ID"
current_body=$(gh release view "$_RELEASE_ID" --json body --jq .body)
echo "Getting current release body. Tag: $_TAG_NAME"
current_body=$(gh release view "$_TAG_NAME" --json body --jq .body)
product_release_notes=$(cat product_release_notes.txt)
@@ -249,7 +205,7 @@ jobs:
${current_body}
**Builds Source:** https://github.com/${{ github.repository }}/actions/runs/$ARTIFACT_RUN_ID"
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
new_release_url=$(gh release edit "$_TAG_NAME" --notes "$updated_body")
# draft release links change after editing
echo "release_url=$new_release_url" >> $GITHUB_OUTPUT

View File

@@ -71,10 +71,10 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -147,8 +147,7 @@ jobs:
bundle exec fastlane updateReleaseNotes \
releaseNotes:"$RELEASE_NOTES" \
versionCode:"$VERSION_CODE" \
packageName:"$PACKAGE_NAME"
versionCode:"$VERSION_CODE"
bundle exec fastlane promoteToProduction \
versionCode:"$VERSION_CODE" \

View File

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

View File

@@ -6,30 +6,92 @@ on:
branches:
- "main"
permissions: {}
jobs:
sast:
name: Checkmarx
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
name: SAST scan
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
security-events: write
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "CHECKMARX-TENANT,CHECKMARX-CLIENT-ID,CHECKMARX-SECRET"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@ef93013c95adc60160bc22060875e90800d3ecfc # 2.3.19
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ steps.get-kv-secrets.outputs.CHECKMARX-TENANT }}
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ steps.get-kv-secrets.outputs.CHECKMARX-CLIENT-ID }}
cx_client_secret: ${{ steps.get-kv-secrets.outputs.CHECKMARX-SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path .
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
with:
sarif_file: cx_result.sarif
quality:
name: Sonar
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
name: Quality scan
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "SONAR-TOKEN"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Scan with SonarCloud
uses: sonarsource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
env:
SONAR_TOKEN: ${{ steps.get-kv-secrets.outputs.SONAR-TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}

View File

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

View File

@@ -1,227 +0,0 @@
name: SDLC / SDK Update
run-name: "SDK ${{inputs.run-mode == 'Update' && format('Update - {0}', inputs.sdk-version) || format('Test #{0} - {1}', inputs.pr-id, inputs.sdk-version)}}"
on:
workflow_dispatch:
inputs:
run-mode:
description: "Run Mode"
type: choice
options:
- Test # used for testing sdk-internal repo PRs
- Update # opens a PR in this repo updating the SDK
default: Test
sdk-package:
description: "SDK Package ID"
required: true
default: "com.bitwarden:sdk-android.dev"
sdk-version:
description: "SDK Version"
required: true
default: "1.0.0-2686-km-update-kdf-sdk"
pr-id:
description: "Pull Request ID"
env:
_BOT_NAME: "bw-ghapp[bot]"
_BOT_EMAIL: "178206702+bw-ghapp[bot]@users.noreply.github.com"
jobs:
update:
name: Update and PR
if: ${{ inputs.run-mode == 'Update' }}
runs-on: ubuntu-24.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-pull-requests: write
permission-actions: read
permission-contents: write
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
with:
inputs: ${{ toJson(inputs) }}
- name: Switch to branch
id: switch-branch
run: |
BRANCH_NAME="sdlc/sdk-update"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
if git switch $BRANCH_NAME; then
echo "✅ Switched to existing branch: $BRANCH_NAME"
echo "updating_existing_branch=true" >> $GITHUB_OUTPUT
else
echo "📝 Creating new branch: $BRANCH_NAME"
git switch -c $BRANCH_NAME
echo "updating_existing_branch=false" >> $GITHUB_OUTPUT
fi
- name: Prevent updating the branch when the last committer isn't the bot
if: ${{ steps.switch-branch.outputs.updating_existing_branch == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
run: |
LATEST_COMMIT_AUTHOR=$(git log -1 --format='%ae' $_BRANCH_NAME)
echo "Latest commit author in branch ($_BRANCH_NAME): $LATEST_COMMIT_AUTHOR"
echo "Expected bot email: $_BOT_EMAIL"
if [ "$LATEST_COMMIT_AUTHOR" != "$_BOT_EMAIL" ]; then
echo "::error::Branch $_BRANCH_NAME has a commit not made by the bot." \
"This indicates manual changes have been made to the branch," \
"PR has to be merged or closed before running this workflow again."
echo "👀 Fetching existing PR..."
gh pr list --head $_BRANCH_NAME --base main --state open --json number --jq '.[0].number // empty'
EXISTING_PR=$(gh pr list --head $_BRANCH_NAME --base main --state open --json number --jq '.[0].number // empty')
if [ -z "$EXISTING_PR" ]; then
echo "::error::Couldn't find an existing PR for branch $_BRANCH_NAME."
exit 1
fi
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
echo "## ❌ Merge or close: $PR_URL" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "✅ Branch tip commit was made by the bot. Safe to proceed."
# Using main to retrieve the changelog on consecutive updates of the same PR.
- name: Get current SDK version from main branch
id: get-current-sdk
run: |
git show origin/main:gradle/libs.versions.toml
SDK_VERSION=$(git show origin/main:gradle/libs.versions.toml | grep "bitwardenSdk =" | cut -d'"' -f2)
if [ -z "$SDK_VERSION" ]; then
echo "::error::Failed to get current SDK version from main branch."
exit 1
fi
GIT_REF=$(echo "$SDK_VERSION" | cut -d'-' -f3-) # handles both commit hashes and branch names
echo "Current SDK version (from main): $SDK_VERSION"
echo "Current SDK git ref: $GIT_REF"
echo "version=$SDK_VERSION" >> $GITHUB_OUTPUT
echo "git_ref=$GIT_REF" >> $GITHUB_OUTPUT
- name: Update SDK Version
env:
_SDK_PACKAGE: ${{ inputs.sdk-package }}
_SDK_VERSION: ${{ inputs.sdk-version }}
run: |
./scripts/update-sdk-version.sh "$_SDK_PACKAGE" "$_SDK_VERSION"
- name: Create branch and commit
env:
_SDK_PACKAGE: ${{ inputs.sdk-package }}
_SDK_VERSION: ${{ inputs.sdk-version }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
run: |
echo "👀 Committing SDK version update..."
git config user.name "$_BOT_NAME"
git config user.email "$_BOT_EMAIL"
git add gradle/libs.versions.toml
git commit -m "SDK Update - $_SDK_PACKAGE $_SDK_VERSION"
git push origin $_BRANCH_NAME
- name: Create or Update Pull Request
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
_SDK_PACKAGE: ${{ inputs.sdk-package }}
_SDK_VERSION: ${{ inputs.sdk-version }}
_OLD_SDK_VERSION: ${{ steps.get-current-sdk.outputs.version }}
_OLD_SDK_GIT_REF: ${{ steps.get-current-sdk.outputs.git_ref }}
run: |
NEW_SDK_GIT_REF=$(echo "$_SDK_VERSION" | cut -d'-' -f3-)
CHANGELOG=$(./scripts/get-repo-changelog.sh "bitwarden/sdk-internal" "$_OLD_SDK_GIT_REF" "$NEW_SDK_GIT_REF")
PR_BODY="Updates the SDK version from \`$_OLD_SDK_VERSION\` to \`$_SDK_PACKAGE $_SDK_VERSION\`
## What's Changed
$CHANGELOG"
EXISTING_PR=$(gh pr list --head $_BRANCH_NAME --base main --state open --json number --jq '.[0].number // empty')
if [ -n "$EXISTING_PR" ]; then
echo "🔄 Updating existing PR #$EXISTING_PR..."
echo -e "$PR_BODY" | gh pr edit $EXISTING_PR \
--title "Update SDK to $_SDK_VERSION" \
--body-file -
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
echo "## ✅ Updated PR: $PR_URL" >> $GITHUB_STEP_SUMMARY
else
echo "📝 Creating new PR..."
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
--title "Update SDK to $_SDK_VERSION" \
--body-file - \
--base main \
--head $_BRANCH_NAME \
--label "automated-pr" \
--label "t:ci")
echo "## 🚀 Created PR: $PR_URL" >> $GITHUB_STEP_SUMMARY
fi
test:
name: Test Update
if: ${{ inputs.run-mode == 'Test' }}
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
with:
inputs: ${{ toJson(inputs) }}
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Update SDK Version
env:
_SDK_PACKAGE: ${{ inputs.sdk-package }}
_SDK_VERSION: ${{ inputs.sdk-version }}
run: |
./scripts/update-sdk-version.sh "$_SDK_PACKAGE" "$_SDK_VERSION"
- name: Build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
run: |
./gradlew assembleDebug --warn

View File

@@ -27,13 +27,13 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/caches
@@ -43,7 +43,7 @@ jobs:
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ github.workspace }}/build-cache
@@ -52,7 +52,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
bundler-cache: true
@@ -91,7 +91,7 @@ jobs:
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:

7
.gitignore vendored
View File

@@ -3,13 +3,6 @@
fastlane/report.xml
fastlane/README.md
# Ruby / Bundler
.bundle/
vendor/
# Backup files
*.bak
# General
.DS_Store
Thumbs.db

View File

@@ -11,27 +11,25 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1139.0)
aws-sdk-core (3.228.0)
aws-partitions (1.1131.0)
aws-sdk-core (3.226.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.109.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.195.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-s3 (1.193.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.2.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -169,7 +167,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.13.2)
json (2.13.0)
jwt (2.10.2)
base64
logger (1.7.0)

View File

@@ -62,37 +62,6 @@
- Hit `Download`.
- Hit `Apply`.
5. Setup `detekt` pre-commit hook (optional):
Run the following script from the root of the repository to install the hook. This will overwrite any existing pre-commit hook if present.
```shell
echo "Writing detekt pre-commit hook..."
cat << 'EOL' > .git/hooks/pre-commit
#!/usr/bin/env bash
echo "Running detekt check..."
OUTPUT="/tmp/detekt-$(date +%s)"
./gradlew -Pprecommit=true detekt > $OUTPUT
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
cat $OUTPUT
rm $OUTPUT
echo "***********************************************"
echo " detekt failed "
echo " Please fix the above issues before committing "
echo "***********************************************"
exit $EXIT_CODE
fi
rm $OUTPUT
EOL
echo "detekt pre-commit hook written to .git/hooks/pre-commit"
echo "Making the hook executable"
chmod +x .git/hooks/pre-commit
echo "detekt pre-commit hook installed successfully to .git/hooks/pre-commit"
```
## Theme
### Icons & Illustrations

View File

@@ -55,15 +55,13 @@ android {
applicationId = "com.x8bit.bitwarden"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
versionCode = 1
versionName = "2025.7.0"
setProperty("archivesBaseName", "com.x8bit.bitwarden")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Set the base archive name for publishing purposes. This is used to derive the APK and AAB
// artifact names when uploading to Firebase and Play Store.
base.archivesName = "com.x8bit.bitwarden"
buildConfigField(
type = "String",
name = "CI_INFO",
@@ -328,7 +326,6 @@ private fun renameFile(path: String, newName: String) {
if (originalFile.renameTo(newFile)) {
println("Renamed $originalFile to $newFile")
} else {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException("Failed to rename $originalFile to $newFile")
}
}

View File

@@ -1,264 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "11387825dab701f9d2dd2e940ffbd794",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasTotp",
"columnName": "has_totp",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canManage",
"columnName": "manage",
"affinity": "INTEGER"
},
{
"fieldPath": "defaultUserCollectionEmail",
"columnName": "default_user_collection_email",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'0'"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11387825dab701f9d2dd2e940ffbd794')"
]
}
}

View File

@@ -115,11 +115,11 @@
android:theme="@android:style/Theme.NoDisplay" />
<activity
android:name=".AutofillCallbackActivity"
android:name=".AutofillTotpCopyActivity"
android:exported="true"
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@style/AutofillCallbackTheme" />
android:theme="@style/AutofillTotpCopyTheme" />
<activity
android:name=".AuthCallbackActivity"
@@ -133,6 +133,16 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="captcha-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="duo-callback"
android:scheme="bitwarden" />

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
@@ -26,6 +27,7 @@ class AuthCallbackViewModel @Inject constructor(
private fun handleIntentReceived(action: AuthCallbackAction.IntentReceive) {
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
val webAuthResult = action.intent.getWebAuthResultOrNull()
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
when {
@@ -33,6 +35,12 @@ class AuthCallbackViewModel @Inject constructor(
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
}
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = captchaCallbackTokenResult,
)
}
duoCallbackTokenResult != null -> {
authRepository.setDuoCallbackTokenResult(
tokenResult = duoCallbackTokenResult,

View File

@@ -15,18 +15,18 @@ import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* An activity that is launched to complete Autofill. This is done when an autofill item is selected
* and is associated with a valid cipher. Due to the constraints of the autofill framework, we also
* have to re-fulfill the autofill for the views that are being filled.
* An activity for copying a TOTP code to the clipboard. This is done when an autofill item is
* selected and it requires TOTP authentication. Due to the constraints of the autofill framework,
* we also have to re-fulfill the autofill for the views that are being filled.
*/
@OmitFromCoverage
@AndroidEntryPoint
class AutofillCallbackActivity : AppCompatActivity() {
class AutofillTotpCopyActivity : AppCompatActivity() {
@Inject
lateinit var autofillCompletionManager: AutofillCompletionManager
private val viewModel: AutofillCallbackViewModel by viewModels()
private val autofillTotpCopyViewModel: AutofillTotpCopyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
@@ -34,7 +34,11 @@ class AutofillCallbackActivity : AppCompatActivity() {
observeViewModelEvents()
viewModel.trySendAction(AutofillCallbackAction.IntentReceived(intent = intent))
autofillTotpCopyViewModel.trySendAction(
AutofillTotpCopyAction.IntentReceived(
intent = intent,
),
)
}
override fun onNewIntent(intent: Intent) {
@@ -46,12 +50,17 @@ class AutofillCallbackActivity : AppCompatActivity() {
}
private fun observeViewModelEvents() {
viewModel
autofillTotpCopyViewModel
.eventFlow
.onEach { event ->
when (event) {
is AutofillCallbackEvent.CompleteAutofill -> handleCompleteAutofill(event)
is AutofillCallbackEvent.FinishActivity -> finishActivity()
is AutofillTotpCopyEvent.CompleteAutofill -> {
handleCompleteAutofill(event)
}
is AutofillTotpCopyEvent.FinishActivity -> {
finishActivity()
}
}
}
.launchIn(lifecycleScope)
@@ -60,7 +69,7 @@ class AutofillCallbackActivity : AppCompatActivity() {
/**
* Complete autofill with the provided data.
*/
private fun handleCompleteAutofill(event: AutofillCallbackEvent.CompleteAutofill) {
private fun handleCompleteAutofill(event: AutofillTotpCopyEvent.CompleteAutofill) {
autofillCompletionManager.completeAutofill(
activity = this,
cipherView = event.cipherView,

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.util.getAutofillCallbackIntentOrNull
import com.x8bit.bitwarden.data.autofill.util.getTotpCopyIntentOrNull
import com.x8bit.bitwarden.data.platform.util.launchWithTimeout
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -13,7 +13,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import timber.log.Timber
import javax.inject.Inject
/**
@@ -22,63 +21,45 @@ import javax.inject.Inject
private const val CIPHER_WAIT_TIMEOUT_MILLIS: Long = 500
/**
* A view model that handles logic for the [AutofillCallbackActivity].
* A view model that handles logic for the [AutofillTotpCopyActivity].
*/
@HiltViewModel
class AutofillCallbackViewModel @Inject constructor(
class AutofillTotpCopyViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<Unit, AutofillCallbackEvent, AutofillCallbackAction>(Unit) {
) : BaseViewModel<Unit, AutofillTotpCopyEvent, AutofillTotpCopyAction>(Unit) {
private val activeUserId: String? get() = authRepository.activeUserId
override fun handleAction(action: AutofillCallbackAction): Unit = when (action) {
is AutofillCallbackAction.IntentReceived -> handleIntentReceived(action)
override fun handleAction(action: AutofillTotpCopyAction): Unit = when (action) {
is AutofillTotpCopyAction.IntentReceived -> handleIntentReceived(action)
}
/**
* Process the received intent and alert the activity of what to do next.
*/
private fun handleIntentReceived(action: AutofillCallbackAction.IntentReceived) {
private fun handleIntentReceived(action: AutofillTotpCopyAction.IntentReceived) {
viewModelScope
.launchWithTimeout(
timeoutBlock = {
Timber.w("Autofill -- Timeout")
finishActivity()
},
timeoutBlock = { finishActivity() },
timeoutDuration = CIPHER_WAIT_TIMEOUT_MILLIS,
) {
// Extract TOTP copy data from the intent.
val cipherId = action
.intent
.getAutofillCallbackIntentOrNull()
.getTotpCopyIntentOrNull()
?.cipherId
if (cipherId == null) {
Timber.w("Autofill -- Cipher was not provided")
finishActivity()
return@launchWithTimeout
}
if (isVaultLocked()) {
Timber.w("Autofill -- Vault is locked")
if (cipherId == null || isVaultLocked()) {
finishActivity()
return@launchWithTimeout
}
// Try and find the matching cipher.
when (val result = vaultRepository.getCipher(cipherId = cipherId)) {
GetCipherResult.CipherNotFound -> {
Timber.w("Autofill -- Cipher not found")
finishActivity()
}
is GetCipherResult.Failure -> {
Timber.w(result.error, "Autofill -- Get cipher failure")
finishActivity()
}
GetCipherResult.CipherNotFound -> finishActivity()
is GetCipherResult.Failure -> finishActivity()
is GetCipherResult.Success -> {
Timber.d("Autofill -- Cipher found")
sendEvent(AutofillCallbackEvent.CompleteAutofill(result.cipherView))
sendEvent(AutofillTotpCopyEvent.CompleteAutofill(result.cipherView))
}
}
}
@@ -88,7 +69,7 @@ class AutofillCallbackViewModel @Inject constructor(
* Send an event to the activity that signals it to finish.
*/
private fun finishActivity() {
sendEvent(AutofillCallbackEvent.FinishActivity)
sendEvent(AutofillTotpCopyEvent.FinishActivity)
}
private suspend fun isVaultLocked(): Boolean {
@@ -105,30 +86,30 @@ class AutofillCallbackViewModel @Inject constructor(
}
/**
* Represents actions that can be sent to the [AutofillCallbackViewModel].
* Represents actions that can be sent to the [AutofillTotpCopyViewModel].
*/
sealed class AutofillCallbackAction {
sealed class AutofillTotpCopyAction {
/**
* An [intent] has been received and is ready to be processed.
*/
data class IntentReceived(
val intent: Intent,
) : AutofillCallbackAction()
) : AutofillTotpCopyAction()
}
/**
* Represents events emitted by the [AutofillCallbackViewModel].
* Represents events emitted by the [AutofillTotpCopyViewModel].
*/
sealed class AutofillCallbackEvent {
sealed class AutofillTotpCopyEvent {
/**
* Complete autofill with the provided [cipherView].
*/
data class CompleteAutofill(
val cipherView: CipherView,
) : AutofillCallbackEvent()
) : AutofillTotpCopyEvent()
/**
* Finish the activity.
*/
data object FinishActivity : AutofillCallbackEvent()
data object FinishActivity : AutofillTotpCopyEvent()
}

View File

@@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject
@@ -39,17 +38,6 @@ class BitwardenApplication : Application() {
@Inject
lateinit var restrictionManager: RestrictionManager
@Inject
lateinit var environmentRepository: EnvironmentRepository
override fun onCreate() {
super.onCreate()
// These must be initialized in order to ensure that the restrictionManager does not
// override the environmentRepository values.
restrictionManager.initialize()
environmentRepository.initialize()
}
override fun onLowMemory() {
super.onLowMemory()
Timber.w("onLowMemory")

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -14,7 +15,6 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -183,6 +183,12 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
is MainEvent.ShowToast -> {
Toast
.makeText(baseContext, event.message.invoke(resources), Toast.LENGTH_SHORT)
.show()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),
@@ -215,7 +221,7 @@ class MainActivity : AppCompatActivity() {
}
private fun handleRecreate() {
ActivityCompat.recreate(this)
recreate()
}
private fun updateScreenCapture(isScreenCaptureAllowed: Boolean) {

View File

@@ -4,11 +4,11 @@ import android.content.Intent
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@@ -36,6 +36,7 @@ import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticato
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
@@ -43,15 +44,12 @@ import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -60,12 +58,11 @@ import java.time.Clock
import javax.inject.Inject
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
private const val ANIMATION_REFRESH_DELAY = 500L
/**
* A view model that helps launch actions for the [MainActivity].
*/
@OptIn(FlowPreview::class)
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MainViewModel @Inject constructor(
@@ -83,7 +80,6 @@ class MainViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val appResumeManager: AppResumeManager,
private val clock: Clock,
private val toastManager: ToastManager,
) : BaseViewModel<MainState, MainEvent, MainAction>(
initialState = MainState(
theme = settingsRepository.appTheme,
@@ -139,23 +135,36 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
merge(
authRepository
.userStateFlow
.drop(count = 1)
// Trigger an action whenever the current user changes or we go into/out of a
// pending account state (which acts like switching to a temporary user).
.map { it?.activeUserId to it?.hasPendingAccountAddition }
.distinctUntilChanged(),
vaultRepository
.vaultStateEventFlow
.filter { it is VaultStateEvent.Locked },
)
// This debounce ensure we do not emit multiple times rapidly and also acts as a short
// delay to give animations time to finish (ex: account switcher).
.debounce(timeoutMillis = ANIMATION_DEBOUNCE_DELAY_MS)
.map { MainAction.Internal.CurrentUserOrVaultStateChange }
.onEach(::sendAction)
authRepository
.userStateFlow
.drop(count = 1)
// Trigger an action whenever the current user changes or we go into/out of a pending
// account state (which acts like switching to a temporary user).
.map { it?.activeUserId to it?.hasPendingAccountAddition }
.distinctUntilChanged()
.onEach {
// Switching between account states often involves some kind of animation (ex:
// account switcher) that we might want to give time to finish before triggering
// a refresh.
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.CurrentUserStateChange)
}
.launchIn(viewModelScope)
vaultRepository
.vaultStateEventFlow
.onEach {
when (it) {
is VaultStateEvent.Locked -> {
// Similar to account switching, triggering this action too soon can
// interfere with animations or navigation logic, so we will delay slightly.
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.VaultUnlockStateChange)
}
is VaultStateEvent.Unlocked -> Unit
}
}
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
@@ -193,12 +202,10 @@ class MainViewModel @Inject constructor(
handleAutofillSelectionReceive(action)
}
is MainAction.Internal.CurrentUserOrVaultStateChange -> {
handleCurrentUserOrVaultStateChange()
}
is MainAction.Internal.CurrentUserStateChange -> handleCurrentUserStateChange()
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
}
}
@@ -232,9 +239,8 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.CompleteAutofill(cipherView = action.cipherView))
}
private fun handleCurrentUserOrVaultStateChange() {
sendEvent(MainEvent.Recreate)
garbageCollectionManager.tryCollect()
private fun handleCurrentUserStateChange() {
recreateUiAndGarbageCollect()
}
private fun handleScreenCaptureUpdate(action: MainAction.Internal.ScreenCaptureUpdate) {
@@ -246,6 +252,10 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.UpdateAppTheme(osTheme = action.theme.osValue))
}
private fun handleVaultUnlockStateChange() {
recreateUiAndGarbageCollect()
}
private fun handleDynamicColorsUpdate(action: MainAction.Internal.DynamicColorsUpdate) {
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
@@ -421,6 +431,11 @@ class MainViewModel @Inject constructor(
}
}
private fun recreateUiAndGarbageCollect() {
sendEvent(MainEvent.Recreate)
garbageCollectionManager.tryCollect()
}
private fun handleCompleteRegistrationData(data: CompleteRegistrationData) {
viewModelScope.launch {
// Attempt to load the environment for the user if they have a pre-auth environment
@@ -433,15 +448,16 @@ class MainViewModel @Inject constructor(
)
when (emailTokenResult) {
is EmailTokenResult.Error -> {
emailTokenResult
.message
?.let { toastManager.show(message = it) }
?: run {
toastManager.show(
messageId = BitwardenString
.there_was_an_issue_validating_the_registration_token,
)
}
sendEvent(
MainEvent.ShowToast(
message = emailTokenResult
.message
?.asText()
?: BitwardenString
.there_was_an_issue_validating_the_registration_token
.asText(),
),
)
}
EmailTokenResult.Expired -> {
@@ -531,9 +547,9 @@ sealed class MainAction {
) : Internal()
/**
* Indicates a relevant change in the current user state or vault locked state.
* Indicates a relevant change in the current user state.
*/
data object CurrentUserOrVaultStateChange : Internal()
data object CurrentUserStateChange : Internal()
/**
* Indicates that the screen capture state has changed.
@@ -549,6 +565,11 @@ sealed class MainAction {
val theme: AppTheme,
) : Internal()
/**
* Indicates a relevant change in the current vault lock state.
*/
data object VaultUnlockStateChange : Internal()
/**
* Indicates that the dynamic colors state has changed.
*/
@@ -584,6 +605,11 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.provider.AppIdProvider
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -127,34 +126,13 @@ interface AuthDiskSource : AppIdProvider {
/**
* Retrieves a private key using a [userId].
*/
@Deprecated(
message = "Use getAccountKeys instead.",
replaceWith = ReplaceWith("getAccountKeys"),
)
fun getPrivateKey(userId: String): String?
/**
* Stores a private key using a [userId].
*/
@Deprecated(
message = "Use storeAccountKeys instead.",
replaceWith = ReplaceWith("storeAccountKeys"),
)
fun storePrivateKey(userId: String, privateKey: String?)
/**
* Returns the profile account keys for the given [userId].
*/
fun getAccountKeys(userId: String): AccountKeysJson?
/**
* Stores the profile account keys for the given [userId].
*/
fun storeAccountKeys(
userId: String,
accountKeys: AccountKeysJson?,
)
/**
* Retrieves a user auto-unlock key for the given [userId].
*/

View File

@@ -4,7 +4,6 @@ import android.content.SharedPreferences
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
@@ -49,7 +48,6 @@ 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 LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys"
/**
* Primary implementation of [AuthDiskSource].
@@ -144,7 +142,6 @@ class AuthDiskSourceImpl(
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
storeEncryptedPin(userId = userId, encryptedPin = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
storeOrganizationKeys(userId = userId, organizationKeys = null)
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricInitVector(userId = userId, iv = null)
@@ -231,11 +228,9 @@ class AuthDiskSourceImpl(
)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
@Deprecated("Use storeAccountKeys instead.", replaceWith = ReplaceWith("storeAccountKeys"))
override fun storePrivateKey(userId: String, privateKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId),
@@ -243,20 +238,6 @@ class AuthDiskSourceImpl(
)
}
override fun getAccountKeys(userId: String): AccountKeysJson? =
getEncryptedString(key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId))
?.let { json.decodeFromStringOrNull(it) }
override fun storeAccountKeys(
userId: String,
accountKeys: AccountKeysJson?,
) {
putEncryptedString(
key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId),
value = accountKeys?.let { json.encodeToString(it) },
)
}
override fun getUserAutoUnlockKey(userId: String): String? =
getEncryptedString(
key = USER_AUTO_UNLOCK_KEY_KEY.appendIdentifier(userId),

View File

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

View File

@@ -8,12 +8,12 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.util.createPasswordlessRequestDataIntent
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import kotlinx.coroutines.CoroutineScope

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import com.bitwarden.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
@@ -28,10 +27,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
@@ -44,12 +45,23 @@ import kotlinx.coroutines.flow.StateFlow
* Provides an API for observing an modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateManager {
interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
/**
* Models the current auth state.
*/
val authStateFlow: StateFlow<AuthState>
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setCaptchaCallbackTokenResult] is called.
*/
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
/**
* Flow of the current [DuoCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setDuoCallbackTokenResult] is called.
@@ -105,6 +117,15 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
*/
var shouldTrustDevice: Boolean
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Return the cached password policies for the current user.
*/
@@ -126,6 +147,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
*/
val showWelcomeCarousel: Boolean
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/
fun clearPendingAccountDeletion()
/**
* Attempt to delete the current account using the [masterPassword] and log them out
* upon success.
@@ -160,6 +186,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
suspend fun login(
email: String,
password: String,
captchaToken: String?,
): LoginResult
/**
@@ -174,6 +201,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
asymmetricalKey: String,
requestPrivateKey: String,
masterPasswordHash: String?,
captchaToken: String?,
): LoginResult
/**
@@ -185,6 +213,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
email: String,
password: String?,
twoFactorData: TwoFactorDataModel,
captchaToken: String?,
orgIdentifier: String?,
): LoginResult
@@ -197,6 +226,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult
@@ -209,6 +239,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
email: String,
password: String?,
newDeviceOtp: String,
captchaToken: String?,
orgIdentifier: String?,
): LoginResult
@@ -263,6 +294,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult
@@ -300,6 +332,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
passwordHint: String?,
): SetPasswordResult
/**
* Set the value of [captchaTokenResultFlow].
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Set the value of [duoTokenResultFlow].
*/

View File

@@ -46,6 +46,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
@@ -54,7 +55,6 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@@ -77,35 +77,51 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.CoroutineScope
@@ -113,11 +129,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
@@ -127,7 +145,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import kotlinx.coroutines.flow.update
import javax.inject.Singleton
/**
@@ -136,7 +154,6 @@ import javax.inject.Singleton
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton
class AuthRepositoryImpl(
private val clock: Clock,
private val accountsService: AccountsService,
private val devicesService: DevicesService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
@@ -145,7 +162,6 @@ class AuthRepositoryImpl(
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val configDiskSource: ConfigDiskSource,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
@@ -155,13 +171,12 @@ class AuthRepositoryImpl(
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
private val userStateManager: UserStateManager,
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
UserStateManager by userStateManager {
AuthRequestManager by authRequestManager {
/**
* A scope intended for use when simply collecting multiple flows in order to combine them. The
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
@@ -174,6 +189,24 @@ class AuthRepositoryImpl(
*/
private val ioScope = CoroutineScope(dispatcherManager.io)
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false)
/**
* If there is a pending account deletion, continue showing the original UserState until it
* is confirmed. This is accomplished by blocking the emissions of the [userStateFlow]
* whenever set to `true`.
*/
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(false)
/**
* Whenever a function needs to update multiple underlying data-points that contribute to the
* [UserState], we update this [MutableStateFlow] and continue to show the original `UserState`
* until the transaction is complete. This is accomplished by blocking the emissions of the
* [userStateFlow] whenever this is set to a value above 0 (a count is used if more than one
* process is updating data simultaneously).
*/
private val mutableUserStateTransactionCountStateFlow = MutableStateFlow(0)
/**
* The auth information to make the identity token request will need to be
* cached to make the request again in the case of two-factor authentication.
@@ -234,6 +267,72 @@ class AuthRepositoryImpl(
initialValue = AuthState.Uninitialized,
)
@Suppress("UNCHECKED_CAST", "MagicNumber")
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
firstTimeActionManager.firstTimeStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
merge(
mutableHasPendingAccountDeletionStateFlow,
mutableUserStateTransactionCountStateFlow,
vaultRepository.isActiveUserUnlockingFlow,
),
) { array ->
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val firstTimeState = array[5] as FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
)
}
.filterNot {
mutableHasPendingAccountDeletionStateFlow.value ||
mutableUserStateTransactionCountStateFlow.value > 0 ||
vaultRepository.isActiveUserUnlockingFlow.value
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultRepository.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
onboardingStatus = authDiskSource.currentOnboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState,
),
)
private val captchaTokenChannel = Channel<CaptchaCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
captchaTokenChannel.receiveAsFlow()
private val duoTokenChannel = Channel<DuoCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val duoTokenResultFlow: Flow<DuoCallbackTokenResult> = duoTokenChannel.receiveAsFlow()
@@ -262,6 +361,9 @@ class AuthRepositoryImpl(
}
}
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
@@ -280,7 +382,7 @@ class AuthRepositoryImpl(
init {
combine(
userStateManager.hasPendingAccountAdditionStateFlow,
mutableHasPendingAccountAdditionStateFlow,
authDiskSource.userStateFlow,
environmentRepository.environmentStateFlow,
) { hasPendingAddition, userState, environment ->
@@ -299,16 +401,14 @@ class AuthRepositoryImpl(
.launchIn(unconfinedScope)
pushManager
.syncOrgKeysFlow
.onEach { userId ->
if (userId == activeUserId) {
// TODO: [PM-20593] Investigate why tokens are explicitly refreshed.
refreshAccessTokenSynchronously(userId = userId)
// We just sync now to get the latest data
vaultRepository.sync(forced = true)
} else {
// We clear the last sync time to ensure we sync when we become the active user
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
}
.onEach {
val userId = activeUserId ?: return@onEach
// TODO: [PM-20593] Investigate why tokens are explicitly refreshed.
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = false,
)
vaultRepository.sync(forced = true)
}
// This requires the ioScope to ensure that refreshAccessTokenSynchronously
// happens on a background thread
@@ -366,12 +466,16 @@ class AuthRepositoryImpl(
.launchIn(unconfinedScope)
}
override fun clearPendingAccountDeletion() {
mutableHasPendingAccountDeletionStateFlow.value = false
}
override suspend fun deleteAccountWithMasterPassword(
masterPassword: String,
): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error(message = null, error = NoActiveUserException())
userStateManager.hasPendingAccountDeletion = true
mutableHasPendingAccountDeletionStateFlow.value = true
return authSdkSource
.hashPassword(
email = profile.email,
@@ -391,7 +495,7 @@ class AuthRepositoryImpl(
override suspend fun deleteAccountWithOneTimePassword(
oneTimePassword: String,
): DeleteAccountResult {
userStateManager.hasPendingAccountDeletion = true
mutableHasPendingAccountDeletionStateFlow.value = true
return accountsService
.deleteAccount(
masterPasswordHash = null,
@@ -403,13 +507,13 @@ class AuthRepositoryImpl(
private fun Result<DeleteAccountResponseJson>.finalizeDeleteAccount(): DeleteAccountResult =
fold(
onFailure = {
userStateManager.hasPendingAccountDeletion = false
clearPendingAccountDeletion()
DeleteAccountResult.Error(error = it, message = null)
},
onSuccess = { response ->
when (response) {
is DeleteAccountResponseJson.Invalid -> {
userStateManager.hasPendingAccountDeletion = false
clearPendingAccountDeletion()
DeleteAccountResult.Error(message = response.message, error = null)
}
@@ -460,10 +564,6 @@ class AuthRepositoryImpl(
.map { keys }
}
.onSuccess { keys ->
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = keys.privateKey,
@@ -492,15 +592,11 @@ class AuthRepositoryImpl(
val profile = authDiskSource.userState?.activeAccount?.profile
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
val userId = profile.userId
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@@ -510,8 +606,6 @@ class AuthRepositoryImpl(
unlockVault(
accountProfile = profile,
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
@@ -526,6 +620,7 @@ class AuthRepositoryImpl(
override suspend fun login(
email: String,
password: String,
captchaToken: String?,
): LoginResult = identityService
.preLogin(email = email)
.flatMap {
@@ -544,6 +639,7 @@ class AuthRepositoryImpl(
username = email,
password = passwordHash,
),
captchaToken = captchaToken,
)
}
.fold(
@@ -563,6 +659,7 @@ class AuthRepositoryImpl(
asymmetricalKey: String,
requestPrivateKey: String,
masterPasswordHash: String?,
captchaToken: String?,
): LoginResult =
loginCommon(
email = email,
@@ -577,12 +674,14 @@ class AuthRepositoryImpl(
asymmetricalKey = asymmetricalKey,
privateKey = requestPrivateKey,
),
captchaToken = captchaToken,
)
override suspend fun login(
email: String,
password: String?,
twoFactorData: TwoFactorDataModel,
captchaToken: String?,
orgIdentifier: String?,
): LoginResult = identityTokenAuthModel
?.let {
@@ -591,6 +690,7 @@ class AuthRepositoryImpl(
password = password,
authModel = it,
twoFactorData = twoFactorData,
captchaToken = captchaToken ?: twoFactorResponse?.captchaToken,
deviceData = twoFactorDeviceData,
orgIdentifier = orgIdentifier,
)
@@ -604,6 +704,7 @@ class AuthRepositoryImpl(
email: String,
password: String?,
newDeviceOtp: String,
captchaToken: String?,
orgIdentifier: String?,
): LoginResult = identityTokenAuthModel
?.let {
@@ -612,6 +713,7 @@ class AuthRepositoryImpl(
password = password,
authModel = it,
newDeviceOtp = newDeviceOtp,
captchaToken = captchaToken ?: twoFactorResponse?.captchaToken,
deviceData = twoFactorDeviceData,
orgIdentifier = orgIdentifier,
)
@@ -645,6 +747,7 @@ class AuthRepositoryImpl(
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult = loginCommon(
email = email,
@@ -653,62 +756,15 @@ class AuthRepositoryImpl(
ssoCodeVerifier = ssoCodeVerifier,
ssoRedirectUri = ssoRedirectUri,
),
captchaToken = captchaToken,
orgIdentifier = organizationIdentifier,
)
override fun refreshAccessTokenSynchronously(
userId: String,
): Result<String> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.flatMap { refreshTokenResponse ->
when (refreshTokenResponse) {
is RefreshTokenResponseJson.Error -> {
if (refreshTokenResponse.isInvalidGrant) {
logout(userId = userId, reason = LogoutReason.InvalidGrant)
}
IllegalStateException(refreshTokenResponse.error).asFailure()
}
is RefreshTokenResponseJson.Forbidden -> {
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
refreshTokenResponse.error.asFailure()
}
is RefreshTokenResponseJson.Unauthorized -> {
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
refreshTokenResponse.error.asFailure()
}
is RefreshTokenResponseJson.Success -> {
// Store the new token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond +
refreshTokenResponse.expiresIn,
),
)
refreshTokenResponse.accessToken.asSuccess()
}
}
}
}
override fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson> =
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = true,
)
override fun logout(reason: LogoutReason) {
activeUserId?.let { userId -> logout(userId = userId, reason = reason) }
@@ -776,7 +832,7 @@ class AuthRepositoryImpl(
// We need to make sure that the environment is set back to the correct spot.
updateEnvironment()
// No switching to do but clear any pending account additions
userStateManager.hasPendingAccountAddition = false
hasPendingAccountAddition = false
return SwitchAccountResult.NoChange
}
@@ -791,7 +847,7 @@ class AuthRepositoryImpl(
authDiskSource.userState = currentUserState.copy(activeUserId = userId)
// Clear any pending account additions
userStateManager.hasPendingAccountAddition = false
hasPendingAccountAddition = false
return SwitchAccountResult.AccountSwitched
}
@@ -802,6 +858,7 @@ class AuthRepositoryImpl(
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult {
@@ -836,6 +893,7 @@ class AuthRepositoryImpl(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
@@ -852,6 +910,7 @@ class AuthRepositoryImpl(
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
captchaResponse = captchaToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
@@ -866,9 +925,18 @@ class AuthRepositoryImpl(
.fold(
onSuccess = {
when (it) {
is RegisterResponseJson.CaptchaRequired -> {
it.validationErrors.captchaKeys.firstOrNull()
?.let { key -> RegisterResult.CaptchaRequired(captchaId = key) }
?: RegisterResult.Error(
errorMessage = null,
error = MissingPropertyException("Captcha ID"),
)
}
is RegisterResponseJson.Success -> {
settingsRepository.hasUserLoggedInOrCreatedAccount = true
RegisterResult.Success
RegisterResult.Success(captchaToken = it.captchaBypassToken)
}
is RegisterResponseJson.Invalid -> {
@@ -1082,9 +1150,6 @@ class AuthRepositoryImpl(
)
.onSuccess {
rsaKeys?.private?.let {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
@@ -1117,6 +1182,10 @@ class AuthRepositoryImpl(
)
}
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
captchaTokenChannel.trySend(tokenResult)
}
override fun setDuoCallbackTokenResult(tokenResult: DuoCallbackTokenResult) {
duoTokenChannel.trySend(tokenResult)
}
@@ -1353,6 +1422,42 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
private fun refreshAccessTokenSynchronouslyInternal(
userId: String,
logOutOnFailure: Boolean,
): Result<RefreshTokenResponseJson> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.onFailure {
if (logOutOnFailure) {
logout(userId = userId, reason = LogoutReason.TokenRefreshFail)
}
}
.onSuccess { refreshTokenResponse ->
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
),
)
}
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
@@ -1454,6 +1559,27 @@ class AuthRepositoryImpl(
)
}
private fun isBiometricsEnabled(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isDeviceTrusted(
userId: String,
): Boolean = authDiskSource.getDeviceKey(userId = userId) != null
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType =
when {
authDiskSource.getPinProtectedUserKey(userId = userId) != null -> {
VaultUnlockType.PIN
}
else -> {
VaultUnlockType.MASTER_PASSWORD
}
}
/**
* Update the saved state with the force password reset reason.
*/
@@ -1487,6 +1613,7 @@ class AuthRepositoryImpl(
twoFactorData: TwoFactorDataModel? = null,
deviceData: DeviceDataModel? = null,
orgIdentifier: String? = null,
captchaToken: String?,
newDeviceOtp: String? = null,
): LoginResult = identityService
.getToken(
@@ -1494,6 +1621,7 @@ class AuthRepositoryImpl(
email = email,
authModel = authModel,
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
captchaToken = captchaToken,
newDeviceOtp = newDeviceOtp,
)
.fold(
@@ -1512,6 +1640,10 @@ class AuthRepositoryImpl(
},
onSuccess = { loginResponse ->
when (loginResponse) {
is GetTokenResponseJson.CaptchaRequired -> LoginResult.CaptchaRequired(
captchaId = loginResponse.captchaKey,
)
is GetTokenResponseJson.TwoFactorRequired -> handleLoginCommonTwoFactorRequired(
loginResponse = loginResponse,
email = email,
@@ -1564,7 +1696,7 @@ class AuthRepositoryImpl(
deviceData: DeviceDataModel?,
orgIdentifier: String?,
userConfirmedKeyConnector: Boolean,
): LoginResult = userStateManager.userStateTransaction {
): LoginResult = userStateTransaction {
val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
@@ -1603,7 +1735,7 @@ class AuthRepositoryImpl(
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
keyConnectorResponse = loginResponse
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
return LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
)
}
@@ -1648,7 +1780,6 @@ class AuthRepositoryImpl(
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true
@@ -1659,18 +1790,11 @@ class AuthRepositoryImpl(
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
// We continue to store the private key for backwards compatibility. Key connector
// conversion still relies on the private key.
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
loginResponse.accountKeys?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storeAccountKeys(userId = userId, accountKeys = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
// of the two-factor code on the next login attempt.
@@ -1771,8 +1895,6 @@ class AuthRepositoryImpl(
masterKey = it.masterKey,
userKey = key,
),
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
)
}
.fold(
@@ -1796,8 +1918,6 @@ class AuthRepositoryImpl(
val result = unlockVault(
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
@@ -1810,16 +1930,10 @@ class AuthRepositoryImpl(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
// We continue to store the private key for backwards compatibility since
// key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
authDiskSource.storeAccountKeys(
userId = profile.userId,
accountKeys = loginResponse.accountKeys,
)
}
result
}
@@ -1841,13 +1955,11 @@ class AuthRepositoryImpl(
): VaultUnlockResult? {
// Attempt to unlock the vault with password if possible.
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKeyOrNull() ?: return null
val privateKey = loginResponse.privateKey ?: return null
val key = loginResponse.key ?: return null
return unlockVault(
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
@@ -1865,15 +1977,13 @@ class AuthRepositoryImpl(
): VaultUnlockResult? {
// Attempt to unlock the vault with auth request if possible.
// These values will only be null during the Just-in-Time provisioning flow.
val privateKey = loginResponse.privateKeyOrNull()
val privateKey = loginResponse.privateKey
val key = loginResponse.key
if (privateKey != null && key != null) {
deviceData?.let { model ->
return unlockVault(
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
@@ -1898,26 +2008,13 @@ class AuthRepositoryImpl(
.userDecryptionOptions
?.trustedDeviceUserDecryptionOptions
?.let { options ->
loginResponse.accountKeys
?.let { accountKeys ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
securityState = accountKeys.securityState?.securityState,
signingKey = accountKeys.signatureKeyPair?.wrappedSigningKey,
)
}
?: loginResponse.privateKey
?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = privateKey,
securityState = null,
signingKey = null,
)
}
loginResponse.privateKey?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = privateKey,
)
}
}
}
@@ -1929,8 +2026,6 @@ class AuthRepositoryImpl(
options: TrustedDeviceUserDecryptionOptionsJson,
profile: AccountJson.Profile,
privateKey: String,
securityState: String?,
signingKey: String?,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = profile.userId
@@ -1949,8 +2044,6 @@ class AuthRepositoryImpl(
vaultUnlockResult = unlockVault(
accountProfile = profile,
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
@@ -1978,8 +2071,6 @@ class AuthRepositoryImpl(
vaultUnlockResult = unlockVault(
accountProfile = profile,
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
@@ -1999,8 +2090,6 @@ class AuthRepositoryImpl(
private suspend fun unlockVault(
accountProfile: AccountJson.Profile,
privateKey: String,
securityState: String?,
signingKey: String?,
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val userId = accountProfile.userId
@@ -2009,8 +2098,6 @@ class AuthRepositoryImpl(
email = accountProfile.email,
kdf = accountProfile.toSdkParams(),
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,
initUserCryptoMethod = initUserCryptoMethod,
// The value for the organization keys here will typically be null. We can separately
// unlock the vault for organization data after receiving the sync response if this
@@ -2037,12 +2124,20 @@ class AuthRepositoryImpl(
}
//endregion LoginCommon
}
/**
* Convenience function to extract the private key from the
* [GetTokenResponseJson.Success] response.
*/
private fun GetTokenResponseJson.Success.privateKeyOrNull(): String? =
this.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: this.privateKey
/**
* Run the given [block] while preventing any updates to [UserState]. This is useful in cases
* where many individual changes might occur that would normally affect the [UserState] but we
* only want a single final emission. In the rare case that multiple threads are running
* transactions simultaneously, there will be no [UserState] updates until the last
* transaction completes.
*/
private inline fun <T> userStateTransaction(block: () -> T): T {
mutableUserStateTransactionCountStateFlow.update { it.inc() }
return try {
block()
} finally {
mutableUserStateTransactionCountStateFlow.update { it.dec() }
}
}
}

View File

@@ -13,11 +13,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManagerImpl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -25,13 +22,11 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@@ -44,7 +39,6 @@ object AuthRepositoryModule {
@Provides
@Singleton
fun providesAuthRepository(
clock: Clock,
accountsService: AccountsService,
devicesService: DevicesService,
identityService: IdentityService,
@@ -53,7 +47,6 @@ object AuthRepositoryModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
configDiskSource: ConfigDiskSource,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
@@ -65,10 +58,9 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
userStateManager: UserStateManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
devicesService = devicesService,
identityService = identityService,
@@ -76,7 +68,6 @@ object AuthRepositoryModule {
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
configDiskSource = configDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatcherManager,
@@ -89,21 +80,7 @@ object AuthRepositoryModule {
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
logsManager = logsManager,
userStateManager = userStateManager,
)
@Provides
@Singleton
fun providesUserStateManager(
authDiskSource: AuthDiskSource,
firstTimeActionManager: FirstTimeActionManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
): UserStateManager = UserStateManagerImpl(
authDiskSource = authDiskSource,
firstTimeActionManager = firstTimeActionManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
logsManager = logsManager,
)
}

View File

@@ -9,6 +9,11 @@ sealed class LoginResult {
*/
data object Success : LoginResult()
/**
* Captcha verification is required.
*/
data class CaptchaRequired(val captchaId: String) : LoginResult()
/**
* Encryption key migration is required.
*/

View File

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

View File

@@ -7,8 +7,16 @@ sealed class RegisterResult {
/**
* Register succeeded.
*
* @param captchaToken the captcha bypass token to bypass future captcha verifications.
*/
data object Success : RegisterResult()
data class Success(val captchaToken: String?) : RegisterResult()
/**
* Captcha verification is required.
*
* @param captchaId the captcha id for performing the captcha verification.
*/
data class CaptchaRequired(val captchaId: String) : RegisterResult()
/**
* There was an error logging in.

View File

@@ -0,0 +1,74 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
import java.util.Locale
private const val CAPTCHA_HOST: String = "captcha-callback"
private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST"
/**
* Generates a [Uri] to display a CAPTCHA challenge for Bitwarden authentication.
*/
fun generateUriForCaptcha(captchaId: String): Uri {
val json = buildJsonObject {
put(key = "siteKey", value = captchaId)
put(key = "locale", value = Locale.getDefault().toString())
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "captchaRequiredText", value = "Captcha required")
}
val base64Data = Base64
.getEncoder()
.encodeToString(
json
.toString()
.toByteArray(Charsets.UTF_8),
)
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=$base64Data&parent=$parentParam&v=1"
return Uri.parse(url)
}
/**
* Retrieves a [CaptchaCallbackTokenResult] from an Intent. There are three possible cases.
*
* - `null`: Intent is not a captcha callback, or data is null.
*
* - [CaptchaCallbackTokenResult.MissingToken]:
* Intent is the captcha callback, but its missing a token value.
*
* - [CaptchaCallbackTokenResult.Success]:
* Intent is the captcha callback, and it has a token.
*/
fun Intent.getCaptchaCallbackTokenResult(): CaptchaCallbackTokenResult? {
val localData = data
return if (
action == Intent.ACTION_VIEW && localData != null && localData.host == CAPTCHA_HOST
) {
localData.getQueryParameter("token")?.let {
CaptchaCallbackTokenResult.Success(token = it)
} ?: CaptchaCallbackTokenResult.MissingToken
} else {
null
}
}
/**
* Sealed class representing the result of captcha callback token extraction.
*/
sealed class CaptchaCallbackTokenResult {
/**
* Represents a missing token in the captcha callback.
*/
data object MissingToken : CaptchaCallbackTokenResult()
/**
* Represents a token present in the captcha callback.
*/
data class Success(val token: String) : CaptchaCallbackTokenResult()
}

View File

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

View File

@@ -8,7 +8,7 @@ import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.util.buildDataset
import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset
import com.x8bit.bitwarden.data.autofill.util.createAutofillCallbackIntentSender
import com.x8bit.bitwarden.data.autofill.util.createTotpCopyIntentSender
import com.x8bit.bitwarden.data.autofill.util.fillableAutofillIds
import timber.log.Timber
@@ -23,7 +23,7 @@ class FillResponseBuilderImpl : FillResponseBuilder {
saveInfo: SaveInfo?,
): FillResponse? =
if (filledData.fillableAutofillIds.isNotEmpty()) {
Timber.d("Autofill request constructing FillResponse")
Timber.w("Autofill request constructing FillResponse")
val fillResponseBuilder = FillResponse.Builder()
saveInfo?.let { nonNullSaveInfo -> fillResponseBuilder.setSaveInfo(nonNullSaveInfo) }
@@ -65,8 +65,8 @@ class FillResponseBuilderImpl : FillResponseBuilder {
}
/**
* Convert this [FilledPartition] and [autofillAppInfo] into an [IntentSender] if the
* [FilledPartition.autofillCipher] has a valid cipher id.
* Convert this [FilledPartition] and [autofillAppInfo] into an [IntentSender] if totp is enabled
* and there the [FilledPartition.autofillCipher] has a valid cipher id.
*/
private fun FilledPartition.toAuthIntentSenderOrNull(
autofillAppInfo: AutofillAppInfo,
@@ -74,7 +74,8 @@ private fun FilledPartition.toAuthIntentSenderOrNull(
autofillCipher
.cipherId
?.let { cipherId ->
createAutofillCallbackIntentSender(
// We always do this even if there is no TOTP code because we want to log the events
createTotpCopyIntentSender(
cipherId = cipherId,
context = autofillAppInfo.context,
)

View File

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

View File

@@ -108,13 +108,11 @@ object AutofillModule {
authRepository: AuthRepository,
cipherMatchingManager: CipherMatchingManager,
vaultRepository: VaultRepository,
policyManager: PolicyManager,
): AutofillCipherProvider =
AutofillCipherProviderImpl(
authRepository = authRepository,
cipherMatchingManager = cipherMatchingManager,
vaultRepository = vaultRepository,
policyManager = policyManager,
)
@Singleton

View File

@@ -17,7 +17,6 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Primary implementation of [AutofillCompletionManager].
@@ -42,7 +41,6 @@ class AutofillCompletionManagerImpl(
.intent
?.getAutofillAssistStructureOrNull()
?: run {
Timber.w("Assist structure not found")
activity.cancelAndFinish()
return
}
@@ -53,7 +51,6 @@ class AutofillCompletionManagerImpl(
assistStructure = assistStructure,
)
if (autofillRequest !is AutofillRequest.Fillable) {
Timber.w("Request is not fillable")
activity.cancelAndFinish()
return
}
@@ -71,13 +68,11 @@ class AutofillCompletionManagerImpl(
authIntentSender = null,
)
?: run {
Timber.w("Dataset not found")
activity.cancelAndFinish()
return@launch
}
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
val resultIntent = createAutofillSelectionResultIntent(dataset)
Timber.d("Autofill success")
activity.setResultAndFinish(resultIntent = resultIntent)
cipherView.id?.let {
organizationEventManager.trackEvent(

View File

@@ -1,14 +0,0 @@
package com.x8bit.bitwarden.data.autofill.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents data for the autofill flow via authentication intents.
*
* @property cipherId The ID of the cipher associated with this Autofill instance.
*/
@Parcelize
data class AutofillCallbackData(
val cipherId: String,
) : Parcelable

View File

@@ -46,7 +46,6 @@ sealed class AutofillCipher {
val expirationMonth: String,
val expirationYear: String,
val number: String,
val brand: String,
) : AutofillCipher() {
override val iconRes: Int
@DrawableRes get() = BitwardenDrawable.ic_payment_card

View File

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

View File

@@ -16,16 +16,13 @@ sealed class AutofillSaveItem : Parcelable {
* @property expirationMonth The expiration month in string form (if applicable).
* @property expirationYear The expiration year in string form (if applicable).
* @property securityCode The security code for the card (if applicable).
* @property cardholderName The name on the card (if applicable).
*/
@Parcelize
data class Card(
val cardholderName: String?,
val number: String?,
val expirationMonth: String?,
val expirationYear: String?,
val securityCode: String?,
val brand: String?,
) : AutofillSaveItem()
/**

View File

@@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.autofill.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents data for a TOTP copying during the autofill flow via authentication intents.
*
* @property cipherId The cipher for which we are copying a TOTP to the clipboard.
*/
@Parcelize
data class AutofillTotpCopyData(
val cipherId: String,
) : Parcelable

View File

@@ -48,28 +48,10 @@ sealed class AutofillView {
) : Card()
/**
* The expiration year [AutofillView] for the [Card] data partition. This implementation
* also has its own [yearValue] because it can be present in lists, in which case there
* is specialized logic for determining its [yearValue]. The [Data.textValue] is very
* likely going to be a very different value.
* The expiration year [AutofillView] for the [Card] data partition.
*/
data class ExpirationYear(
override val data: Data,
val yearValue: String?,
) : Card()
/**
* The expiration date [AutofillView] for the [Card] data partition.
*/
data class ExpirationDate(
override val data: Data,
) : Card()
/**
* The cardholder name [AutofillView] for the [Card] data partition.
*/
data class CardholderName(
override val data: Data,
) : Card()
/**
@@ -85,17 +67,6 @@ sealed class AutofillView {
data class SecurityCode(
override val data: Data,
) : Card()
/**
* The brand [AutofillView] for the [Card] data partition. This implementation also has its
* own [brandValue] because it can be present in lists, in which case there is specialized
* logic for determining its [brandValue]. The [Data.textValue] is very likely going to be
* a very different value.
*/
data class Brand(
override val data: Data,
val brandValue: String?,
) : Card()
}
/**

View File

@@ -135,7 +135,7 @@ class AutofillParserImpl(
// Get inline information if available
val isInlineAutofillEnabled = settingsRepository.isInlineAutofillEnabled
Timber.d("Autofill request isInlineEnabled=$isInlineAutofillEnabled -- ${fillRequest?.id}")
Timber.e("Autofill request isInlineEnabled=$isInlineAutofillEnabled -- ${fillRequest?.id}")
val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = isInlineAutofillEnabled,

View File

@@ -1,13 +1,11 @@
package com.x8bit.bitwarden.data.autofill.provider
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.subtitle
@@ -36,7 +34,6 @@ class AutofillCipherProviderImpl(
private val authRepository: AuthRepository,
private val cipherMatchingManager: CipherMatchingManager,
private val vaultRepository: VaultRepository,
private val policyManager: PolicyManager,
) : AutofillCipherProvider {
private val activeUserId: String? get() = authRepository.activeUserId
@@ -56,9 +53,7 @@ class AutofillCipherProviderImpl(
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
val cipherListViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
val organizationIdsWithCardTypeRestrictions = policyManager
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.map { it.organizationId }
return cipherListViews
.mapNotNull { cipherListView ->
cipherListView
@@ -69,11 +64,7 @@ class AutofillCipherProviderImpl(
// Must not be deleted.
it.deletedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE &&
// Must not be restricted by organization.
!it.isExcludedByOrgCardRestrictions(
organizationIdsWithCardTypeRestrictions,
)
it.reprompt == CipherRepromptType.NONE
}
?.let { nonNullCipherListView ->
nonNullCipherListView.id?.let { cipherId ->
@@ -87,7 +78,6 @@ class AutofillCipherProviderImpl(
expirationMonth = cipherView.card?.expMonth.orEmpty(),
expirationYear = cipherView.card?.expYear.orEmpty(),
number = cipherView.card?.number.orEmpty(),
brand = cipherView.card?.brand.orEmpty(),
)
}
}
@@ -148,33 +138,10 @@ class AutofillCipherProviderImpl(
Timber.e("Cipher not found for autofill.")
null
}
is GetCipherResult.Failure -> {
Timber.e(result.error, "Failed to decrypt cipher for autofill.")
null
}
is GetCipherResult.Success -> result.cipherView
}
/**
* Checks if this [CipherListView] item should be excluded from autofill due to
* organization-based card type restrictions.
*
* It's considered restricted if:
* 1. There are organizations with card type restrictions AND this item is a personal vault item
* (organizationId is null).
* 2. OR this item belongs to an organization that has card type restrictions.
*/
private fun CipherListView.isExcludedByOrgCardRestrictions(
restrictingOrgIds: List<String>,
): Boolean {
if (restrictingOrgIds.isEmpty()) {
return false
}
// If personal vault (no orgId), restricted if any org has restrictions.
return organizationId == null ||
// If part of an org, restricted if that org is in the restricting list.
organizationId in restrictingOrgIds
}
}

View File

@@ -12,19 +12,18 @@ import android.service.autofill.Dataset
import android.view.autofill.AutofillManager
import androidx.core.os.bundleOf
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
import com.x8bit.bitwarden.AutofillCallbackActivity
import com.x8bit.bitwarden.AutofillTotpCopyActivity
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillCallbackData
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.model.AutofillTotpCopyData
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
import kotlin.random.Random
private const val AUTOFILL_SAVE_ITEM_DATA_KEY = "autofill-save-item-data"
private const val AUTOFILL_SELECTION_DATA_KEY = "autofill-selection-data"
private const val AUTOFILL_CALLBACK_DATA_KEY = "autofill-callback-data"
private const val AUTOFILL_TOTP_COPY_DATA_KEY = "autofill-totp-copy-data"
private const val AUTOFILL_BUNDLE_KEY = "autofill-bundle-key"
/**
@@ -55,21 +54,21 @@ fun createAutofillSelectionIntent(
}
/**
* Creates an [IntentSender] built with the data required for performing an Autofill callback
* during the autofill flow.
* Creates an [IntentSender] built with the data required for performing a TOTP copying during
* the autofill flow.
*/
fun createAutofillCallbackIntentSender(
fun createTotpCopyIntentSender(
cipherId: String,
context: Context,
): IntentSender {
val intent = Intent(
context,
AutofillCallbackActivity::class.java,
AutofillTotpCopyActivity::class.java,
)
.putExtra(
AUTOFILL_BUNDLE_KEY,
bundleOf(
AUTOFILL_CALLBACK_DATA_KEY to AutofillCallbackData(cipherId = cipherId),
AUTOFILL_TOTP_COPY_DATA_KEY to AutofillTotpCopyData(cipherId = cipherId),
),
)
return PendingIntent
@@ -143,12 +142,12 @@ fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? =
?.getSafeParcelableExtra(AUTOFILL_SELECTION_DATA_KEY)
/**
* Checks if the given [Intent] contains Autofill callback data. The [AutofillCallbackData] will be
* Checks if the given [Intent] contains data for TOTP copying. The [AutofillTotpCopyData] will be
* returned when present.
*/
fun Intent.getAutofillCallbackIntentOrNull(): AutofillCallbackData? =
fun Intent.getTotpCopyIntentOrNull(): AutofillTotpCopyData? =
getBundleExtra(AUTOFILL_BUNDLE_KEY)
?.getSafeParcelableExtra(AUTOFILL_CALLBACK_DATA_KEY)
?.getSafeParcelableExtra(AUTOFILL_TOTP_COPY_DATA_KEY)
/**
* Checks if the given [Activity] was created for Autofill. This is useful to avoid locking the

View File

@@ -9,19 +9,16 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillView
val AutofillPartition.Card.expirationMonthSaveValue: String?
get() = this
.views
.filterIsInstance<AutofillView.Card.ExpirationMonth>()
.firstOrNull { it.monthValue != null }
?.monthValue
.firstOrNull { it is AutofillView.Card.ExpirationMonth && it.monthValue != null }
?.data
?.textValue
/**
* The text value representation of the year from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.expirationYearSaveValue: String?
get() = this
.views
.filterIsInstance<AutofillView.Card.ExpirationYear>()
.firstOrNull { it.yearValue != null }
?.yearValue
.extractNonNullTextValueOrNull { it is AutofillView.Card.ExpirationYear }
/**
* The text value representation of the card number from the [AutofillPartition.Card].
@@ -37,24 +34,6 @@ val AutofillPartition.Card.securityCodeSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Card.SecurityCode }
/**
* The text value representation of the cardholder name from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.cardholderNameSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Card.CardholderName }
/**
* The text value representation of the brand from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.brandSaveValue: String?
get() = this
.views
.filterIsInstance<AutofillView.Card.Brand>()
.firstOrNull { it.brandValue != null }
?.brandValue
?: this.extractNonNullTextValueOrNull { it is AutofillView.Card.Brand }
/**
* The text value representation of the password from the [AutofillPartition.Login].
*/

View File

@@ -11,12 +11,10 @@ fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem =
when (this.partition) {
is AutofillPartition.Card -> {
AutofillSaveItem.Card(
cardholderName = partition.cardholderNameSaveValue,
number = partition.numberSaveValue,
expirationMonth = partition.expirationMonthSaveValue,
expirationYear = partition.expirationYearSaveValue,
securityCode = partition.securityCodeSaveValue,
brand = partition.brandSaveValue,
)
}

View File

@@ -35,39 +35,3 @@ fun AutofillValue.extractTextValue(): String? =
} else {
null
}
/**
* Extract a year value from this [AutofillValue].
*/
fun AutofillValue.extractYearValue(
autofillOptions: List<String>,
): String? =
when {
this.isList && autofillOptions.isNotEmpty() -> {
autofillOptions.getOrNull(listValue)
}
this.isText -> {
this.textValue.toString()
}
else -> null
}
/**
* Extract a card brand value from this [AutofillValue].
*/
fun AutofillValue.extractCardBrandValue(
autofillOptions: List<String>,
): String? =
when {
this.isList && autofillOptions.isNotEmpty() -> {
autofillOptions.getOrNull(listValue)
}
this.isText -> {
this.textValue.toString()
}
else -> null
}

View File

@@ -4,8 +4,6 @@ import android.view.View
import android.view.autofill.AutofillValue
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
/**
* Convert this [AutofillView] into a [FilledItem]. Return null if not possible.
@@ -13,34 +11,29 @@ import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
fun AutofillView.buildFilledItemOrNull(
value: String,
): FilledItem? =
// Do not try to autofill fields that are empty in the vault
if (value.isEmpty()) {
null
} else {
when (this.data.autofillType) {
View.AUTOFILL_TYPE_DATE -> {
value
.toLongOrNull()
?.let { AutofillValue.forDate(it) }
}
View.AUTOFILL_TYPE_LIST -> this.buildListAutofillValueOrNull(value = value)
View.AUTOFILL_TYPE_TEXT -> AutofillValue.forText(value)
View.AUTOFILL_TYPE_TOGGLE -> {
value
.toBooleanStrictOrNull()
?.let { AutofillValue.forToggle(it) }
}
else -> null
when (this.data.autofillType) {
View.AUTOFILL_TYPE_DATE -> {
value
.toLongOrNull()
?.let { AutofillValue.forDate(it) }
}
?.let { autofillValue ->
FilledItem(
autofillId = this.data.autofillId,
value = autofillValue,
)
}
View.AUTOFILL_TYPE_LIST -> this.buildListAutofillValueOrNull(value = value)
View.AUTOFILL_TYPE_TEXT -> AutofillValue.forText(value)
View.AUTOFILL_TYPE_TOGGLE -> {
value
.toBooleanStrictOrNull()
?.let { AutofillValue.forToggle(it) }
}
else -> null
}
?.let { autofillValue ->
FilledItem(
autofillId = this.data.autofillId,
value = autofillValue,
)
}
/**
* Build a list [AutofillValue] out of [value] or return null if not possible.
@@ -49,50 +42,22 @@ fun AutofillView.buildFilledItemOrNull(
private fun AutofillView.buildListAutofillValueOrNull(
value: String,
): AutofillValue? =
when (this) {
is AutofillView.Card.ExpirationMonth -> {
val autofillOptionsSize = this.data.autofillOptions.size
// The idea here is that `value` is a numerical representation of a month.
val monthIndex = value.toIntOrNull()
when {
monthIndex == null -> null
// We expect there is some placeholder or empty space at the beginning of the list.
autofillOptionsSize == 13 -> AutofillValue.forList(monthIndex)
autofillOptionsSize >= monthIndex -> AutofillValue.forList(monthIndex - 1)
else -> null
}
}
is AutofillView.Card.ExpirationYear -> {
val autofillOptions = this.data.autofillOptions
autofillOptions
.firstOrNull { it == value || it.takeLast(2) == value.takeLast(2) }
?.let { AutofillValue.forList(autofillOptions.indexOf(it)) }
}
is AutofillView.Card.Brand -> {
value.findVaultCardBrandWithNameOrNull()
?.takeUnless { it == VaultCardBrand.SELECT }
?.let { vaultCardBrand ->
this.data.autofillOptions
.firstOrNull { it.findVaultCardBrandWithNameOrNull() == vaultCardBrand }
?.let { AutofillValue.forList(this.data.autofillOptions.indexOf(it)) }
}
}
is AutofillView.Card.CardholderName,
is AutofillView.Card.ExpirationDate,
is AutofillView.Card.Number,
is AutofillView.Card.SecurityCode,
is AutofillView.Login.Password,
is AutofillView.Login.Username,
is AutofillView.Unused,
-> {
this
.data
.autofillOptions
.indexOfFirst { it == value }
.takeIf { it != -1 }
?.let { AutofillValue.forList(it) }
if (this is AutofillView.Card.ExpirationMonth) {
val autofillOptionsSize = this.data.autofillOptions.size
// The idea here is that `value` is a numerical representation of a month.
val monthIndex = value.toIntOrNull()
when {
monthIndex == null -> null
// We expect there is some placeholder or empty space at the beginning of the list.
autofillOptionsSize == 13 -> AutofillValue.forList(monthIndex)
autofillOptionsSize >= monthIndex -> AutofillValue.forList(monthIndex - 1)
else -> null
}
} else {
this
.data
.autofillOptions
.indexOfFirst { it == value }
.takeIf { it != -1 }
?.let { AutofillValue.forList(it) }
}

View File

@@ -24,7 +24,6 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
expirationMonth = card.expMonth.orEmpty(),
expirationYear = card.expYear.orEmpty(),
number = card.number.orEmpty(),
brand = card.brand.orEmpty(),
),
)
}

View File

@@ -9,7 +9,6 @@ import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import android.widget.inline.InlinePresentationSpec
import androidx.annotation.RequiresApi
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData

View File

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

View File

@@ -1,6 +1,9 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.PendingIntent
import android.os.Build
import android.text.InputType
import com.bitwarden.annotation.OmitFromCoverage
/**
* Whether this [Int] is a password [InputType].
@@ -29,3 +32,15 @@ val Int.isUsernameInputType: Boolean
* Whether this [Int] contains [flag].
*/
private fun Int.hasFlag(flag: Int): Boolean = (this and flag) == flag
/**
* Starting from an initial pending intent flag. (ex: [PendingIntent.FLAG_CANCEL_CURRENT])
*/
@OmitFromCoverage
fun Int.toPendingIntentMutabilityFlag(): Int =
// Mutable flag was added on API level 31
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
this or PendingIntent.FLAG_MUTABLE
} else {
this
}

View File

@@ -16,20 +16,3 @@ fun String.containsAnyTerms(
ignoreCase = ignoreCase,
)
}
/**
* Check whether this string matches any of these [expressions].
*/
fun String.matchesAnyExpressions(
expressions: List<Regex>,
): Boolean =
expressions.any {
this.matches(regex = it)
}
/**
* Convert this [String] to lowercase and remove all non-alpha characters.
*/
fun String.toLowerCaseAndStripNonAlpha(): String = this
.lowercase()
.replace(Regex("[^a-z]"), "")

View File

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

View File

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

View File

@@ -12,16 +12,22 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.vault.CipherListView
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.processor.GET_PASSKEY_INTENT
import com.x8bit.bitwarden.data.credentials.processor.GET_PASSWORD_INTENT
import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupported
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlin.random.Random
/**
* Primary implementation of [CredentialEntryBuilder].
*/
class CredentialEntryBuilderImpl(
private val context: Context,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val intentManager: IntentManager,
private val featureFlagManager: FeatureFlagManager,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
) : CredentialEntryBuilder {
@@ -66,12 +72,15 @@ class CredentialEntryBuilderImpl(
context = context,
username = fido2AutofillView.userNameForUi
?: context.getString(BitwardenString.no_username),
pendingIntent = pendingIntentManager.createFido2GetCredentialPendingIntent(
userId = userId,
credentialId = fido2AutofillView.credentialId.toString(),
cipherId = fido2AutofillView.cipherId,
isUserVerified = isUserVerified,
),
pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
userId = userId,
credentialId = fido2AutofillView.credentialId.toString(),
cipherId = fido2AutofillView.cipherId,
isUserVerified = isUserVerified,
requestCode = Random.nextInt(),
),
beginGetPublicKeyCredentialOption = option,
)
.setIcon(
@@ -82,7 +91,10 @@ class CredentialEntryBuilderImpl(
.also { builder ->
if (!isUserVerified) {
builder.setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
cipher = biometricsEncryptionManager
.getOrCreateCipher(userId),
isSingleTapAuthEnabled = featureFlagManager
.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication),
)
}
}
@@ -100,11 +112,14 @@ class CredentialEntryBuilderImpl(
context = context,
username = cipherView.login?.username
?: context.getString(BitwardenString.no_username),
pendingIntent = pendingIntentManager.createPasswordGetCredentialPendingIntent(
userId = userId,
cipherId = cipherView.id,
isUserVerified = isUserVerified,
),
pendingIntent = intentManager
.createPasswordGetCredentialPendingIntent(
action = GET_PASSWORD_INTENT,
userId = userId,
cipherId = cipherView.id,
isUserVerified = isUserVerified,
requestCode = Random.nextInt(),
),
beginGetPasswordOption = option,
)
.setDisplayName(cipherView.name)

View File

@@ -12,8 +12,6 @@ import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilderImpl
import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
@@ -28,6 +26,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -52,18 +51,20 @@ object CredentialProviderModule {
authRepository: AuthRepository,
bitwardenCredentialManager: BitwardenCredentialManager,
dispatcherManager: DispatcherManager,
pendingIntentManager: CredentialManagerPendingIntentManager,
intentManager: IntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
featureFlagManager: FeatureFlagManager,
clock: Clock,
): CredentialProviderProcessor =
CredentialProviderProcessorImpl(
context = context,
authRepository = authRepository,
bitwardenCredentialManager = bitwardenCredentialManager,
pendingIntentManager = pendingIntentManager,
clock = clock,
biometricsEncryptionManager = biometricsEncryptionManager,
dispatcherManager = dispatcherManager,
context,
authRepository,
bitwardenCredentialManager,
intentManager,
clock,
biometricsEncryptionManager,
featureFlagManager,
dispatcherManager,
)
@Provides
@@ -106,11 +107,13 @@ object CredentialProviderModule {
@Singleton
fun provideCredentialEntryBuilder(
@ApplicationContext context: Context,
pendingIntentManager: CredentialManagerPendingIntentManager,
intentManager: IntentManager,
featureFlagManager: FeatureFlagManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
): CredentialEntryBuilder = CredentialEntryBuilderImpl(
context = context,
pendingIntentManager = pendingIntentManager,
intentManager = intentManager,
featureFlagManager = featureFlagManager,
biometricsEncryptionManager = biometricsEncryptionManager,
)
@@ -133,13 +136,4 @@ object CredentialProviderModule {
fun provideRelyingPartyParser(
json: Json,
): RelyingPartyParser = RelyingPartyParserImpl(json)
@Provides
@Singleton
fun provideCredentialManagerPendingIntentManager(
@ApplicationContext context: Context,
): CredentialManagerPendingIntentManager =
CredentialManagerPendingIntentManagerImpl(
context = context,
)
}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
package com.x8bit.bitwarden.data.credentials.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber

View File

@@ -31,14 +31,22 @@ import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
const val GET_PASSWORD_INTENT = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD"
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
/**
* The default implementation of [CredentialProviderProcessor]. Its purpose is to handle
* [CredentialManager] requests from other applications.
@@ -49,12 +57,14 @@ class CredentialProviderProcessorImpl(
private val context: Context,
private val authRepository: AuthRepository,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val pendingIntentManager: CredentialManagerPendingIntentManager,
private val intentManager: IntentManager,
private val clock: Clock,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
) : CredentialProviderProcessor {
private val requestCode = AtomicInteger()
private val ioScope = CoroutineScope(dispatcherManager.io)
override fun processCreateCredentialRequest(
@@ -97,8 +107,10 @@ class CredentialProviderProcessorImpl(
if (!userState.activeAccount.isVaultUnlocked) {
val authenticationAction = AuthenticationAction(
title = context.getString(BitwardenString.unlock),
pendingIntent = pendingIntentManager.createFido2UnlockPendingIntent(
pendingIntent = intentManager.createFido2UnlockPendingIntent(
action = UNLOCK_ACCOUNT_INTENT,
userId = userState.activeUserId,
requestCode = requestCode.getAndIncrement(),
),
)
@@ -173,8 +185,10 @@ class CredentialProviderProcessorImpl(
val entryBuilder = CreateEntry
.Builder(
accountName = accountName,
pendingIntent = pendingIntentManager.createFido2CreationPendingIntent(
userId = userId,
pendingIntent = intentManager.createFido2CreationPendingIntent(
CREATE_PASSKEY_INTENT,
userId,
requestCode.getAndIncrement(),
),
)
.setDescription(
@@ -188,7 +202,9 @@ class CredentialProviderProcessorImpl(
.setLastUsedTime(if (isActive) clock.instant() else null)
.setAutoSelectAllowed(true)
if (isVaultUnlocked) {
if (isVaultUnlocked &&
featureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyCreation)
) {
biometricsEncryptionManager
.getOrCreateCipher(userId)
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }

View File

@@ -14,8 +14,12 @@ import javax.crypto.Cipher
*/
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
isSingleTapAuthEnabled: Boolean,
): PublicKeyCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
cipher != null &&
isSingleTapAuthEnabled
) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)

View File

@@ -8,14 +8,14 @@ import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_USER_ID
import com.x8bit.bitwarden.data.credentials.manager.EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK
/**
* Checks if this [Intent] contains a [CreateCredentialRequest] related to an ongoing

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.bitwardenServiceClient
import com.bitwarden.network.interceptor.BaseUrlsProvider
@@ -14,6 +13,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.util.isDevBuild
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -54,7 +54,6 @@ object PlatformNetworkModule {
baseUrlsProvider: BaseUrlsProvider,
authDiskSource: AuthDiskSource,
certificateManager: CertificateManager,
buildInfoManager: BuildInfoManager,
clock: Clock,
): BitwardenServiceClient = bitwardenServiceClient(
BitwardenServiceClientConfig(
@@ -68,7 +67,7 @@ object PlatformNetworkModule {
authTokenProvider = authTokenManager,
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
enableHttpBodyLogging = isDevBuild,
),
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ interface PushManager {
/**
* Flow that represents requests intended to trigger syncing organization keys.
*/
val syncOrgKeysFlow: Flow<String>
val syncOrgKeysFlow: Flow<Unit>
/**
* Flow that represents requests intended to trigger a sync send delete.

View File

@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
@@ -67,7 +66,7 @@ class PushManagerImpl @Inject constructor(
bufferedMutableSharedFlow<SyncFolderDeleteData>()
private val mutableSyncFolderUpsertSharedFlow =
bufferedMutableSharedFlow<SyncFolderUpsertData>()
private val mutableSyncOrgKeysSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableSyncOrgKeysSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableSyncSendDeleteSharedFlow =
bufferedMutableSharedFlow<SyncSendDeleteData>()
private val mutableSyncSendUpsertSharedFlow =
@@ -94,7 +93,7 @@ class PushManagerImpl @Inject constructor(
override val syncFolderUpsertFlow: SharedFlow<SyncFolderUpsertData>
get() = mutableSyncFolderUpsertSharedFlow.asSharedFlow()
override val syncOrgKeysFlow: SharedFlow<String>
override val syncOrgKeysFlow: SharedFlow<Unit>
get() = mutableSyncOrgKeysSharedFlow.asSharedFlow()
override val syncSendDeleteFlow: SharedFlow<SyncSendDeleteData>
@@ -130,8 +129,8 @@ class PushManagerImpl @Inject constructor(
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun onMessageReceived(notification: BitwardenNotification) {
if (authDiskSource.uniqueAppId == notification.contextId) return
val userId = activeUserId ?: return
Timber.d("Push Notification Received: ${notification.notificationType}")
when (val type = notification.notificationType) {
NotificationType.AUTH_REQUEST,
@@ -190,14 +189,9 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncCipherNotification>(
string = notification.payload,
)
.takeIf { it.userId != null && it.cipherId != null }
?.let {
SyncCipherDeleteData(
userId = requireNotNull(it.userId),
cipherId = requireNotNull(it.cipherId),
)
}
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(it) }
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.cipherId
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(SyncCipherDeleteData(it)) }
}
NotificationType.SYNC_CIPHERS,
@@ -232,24 +226,15 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncFolderNotification>(
string = notification.payload,
)
.takeIf { it.userId != null && it.folderId != null }
?.let {
SyncFolderDeleteData(
userId = requireNotNull(it.userId),
folderId = requireNotNull(it.folderId),
)
}
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(it) }
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.folderId
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(SyncFolderDeleteData(it)) }
}
NotificationType.SYNC_ORG_KEYS -> {
json
.decodeFromString<NotificationPayload.SynchronizeOrganizationKeysNotifications>(
string = notification.payload,
)
.userId
.takeIf { authDiskSource.userState?.accounts.orEmpty().containsKey(it) }
?.let { mutableSyncOrgKeysSharedFlow.tryEmit(it) }
if (isLoggedIn(userId)) {
mutableSyncOrgKeysSharedFlow.tryEmit(Unit)
}
}
NotificationType.SYNC_SEND_CREATE,
@@ -277,14 +262,9 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncSendNotification>(
string = notification.payload,
)
.takeIf { it.userId != null && it.sendId != null }
?.let {
SyncSendDeleteData(
userId = requireNotNull(it.userId),
sendId = requireNotNull(it.sendId),
)
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.sendId
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(SyncSendDeleteData(it)) }
}
}
}

View File

@@ -3,8 +3,6 @@ package com.x8bit.bitwarden.data.platform.manager.di
import android.app.Application
import android.content.Context
import androidx.core.content.getSystemService
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.manager.realtime.RealtimeManagerImpl
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.data.manager.DispatcherManager
@@ -200,10 +198,6 @@ object PlatformManagerModule {
toastManager = toastManager,
)
@Provides
@Singleton
fun provideRealtimeManager(): RealtimeManager = RealtimeManagerImpl()
@Provides
@Singleton
fun provideToastManager(
@@ -335,8 +329,13 @@ object PlatformManagerModule {
@Singleton
fun provideRestrictionManager(
@ApplicationContext context: Context,
appStateManager: AppStateManager,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
): RestrictionManager = RestrictionManagerImpl(
appStateManager = appStateManager,
dispatcherManager = dispatcherManager,
context = context,
environmentRepository = environmentRepository,
restrictionsManager = requireNotNull(context.getSystemService()),
)

View File

@@ -190,7 +190,7 @@ internal class FlightRecorderManagerImpl(
}
}
private inner class FlightRecorderTree : Timber.DebugTree() {
private inner class FlightRecorderTree : Timber.Tree() {
var flightRecorderData: FlightRecorderDataSet.FlightRecorderData? = null
set(value) {
value?.let {

View File

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

View File

@@ -1,4 +1,4 @@
package com.bitwarden.core.data.manager.model
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Class to hold feature flag keys.
@@ -17,26 +17,30 @@ sealed class FlagKey<out T : Any> {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* List of all active Authenticator flag keys.
* List of all flag keys to consider
*/
val activeAuthenticatorFlags: List<FlagKey<*>> by lazy {
listOf(
BitwardenAuthenticationEnabled,
)
}
/**
* List of all active Password Manager flag keys.
*/
val activePasswordManagerFlags: List<FlagKey<*>> by lazy {
val activeFlags: List<FlagKey<*>> by lazy {
listOf(
EmailVerification,
CredentialExchangeProtocolImport,
CredentialExchangeProtocolExport,
SingleTapPasskeyCreation,
SingleTapPasskeyAuthentication,
RestrictCipherItemDeletion,
UserManagedPrivilegedApps,
RemoveCardPolicy,
)
}
}
/**
* Data object holding the key for Email Verification feature.
*/
data object EmailVerification : FlagKey<Boolean>() {
override val keyName: String = "email-verification"
override val defaultValue: Boolean = false
}
/**
* Data object holding hte feature flag key for the Credential Exchange Protocol (CXP) import
* feature.
@@ -63,6 +67,30 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key to enable single tap passkey creation.
*/
data object SingleTapPasskeyCreation : FlagKey<Boolean>() {
override val keyName: String = "single-tap-passkey-creation"
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key to enable single tap passkey authentication.
*/
data object SingleTapPasskeyAuthentication : FlagKey<Boolean>() {
override val keyName: String = "single-tap-passkey-authentication"
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key to enable the restriction of cipher item deletion
*/
data object RestrictCipherItemDeletion : FlagKey<Boolean>() {
override val keyName: String = "pm-15493-restrict-item-deletion-to-can-manage-permission"
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key to enabled user-managed privileged apps.
*/
@@ -72,10 +100,11 @@ sealed class FlagKey<out T : Any> {
}
/**
* Indicates the state of Bitwarden authentication.
* Data object holding the feature flag key to enable the removal of card item types.
* This flag will hide card types from organizations with policy enable and individual vaults
*/
data object BitwardenAuthenticationEnabled : FlagKey<Boolean>() {
override val keyName: String = "bitwarden-authentication-enabled"
data object RemoveCardPolicy : FlagKey<Boolean>() {
override val keyName: String = "pm-16442-remove-card-item-type-policy"
override val defaultValue: Boolean = false
}

View File

@@ -74,13 +74,4 @@ sealed class NotificationPayload {
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
/**
* A notification payload for resynchronizing organization keys.
*/
@Serializable
data class SynchronizeOrganizationKeysNotifications(
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
}

View File

@@ -105,13 +105,4 @@ sealed class OrganizationEvent {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_VIEWED
}
/**
* Tracks when user's individual vault is exported.
*/
data object UserClientExportedVault : OrganizationEvent() {
override val cipherId: String? = null
override val type: OrganizationEventType
get() = OrganizationEventType.USER_CLIENT_EXPORTED_VAULT
}
}

View File

@@ -2,13 +2,13 @@ package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import androidx.credentials.CredentialManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.model.TotpData
import kotlinx.parcelize.Parcelize

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,4 @@ package com.x8bit.bitwarden.data.platform.manager.restriction
/**
* A manager for handling restrictions.
*/
interface RestrictionManager {
/**
* Initializes the [RestrictionManager].
*/
fun initialize()
}
interface RestrictionManager

View File

@@ -1,19 +1,57 @@
package com.x8bit.bitwarden.data.platform.manager.restriction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.RestrictionsManager
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import android.os.Bundle
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* The default implementation of the [RestrictionManager].
*/
class RestrictionManagerImpl(
appStateManager: AppStateManager,
dispatcherManager: DispatcherManager,
private val context: Context,
private val environmentRepository: EnvironmentRepository,
private val restrictionsManager: RestrictionsManager,
) : RestrictionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
private val intentFilter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
private val restrictionsChangedReceiver = RestrictionsChangedReceiver()
private var isReceiverRegistered = false
override fun initialize() {
init {
appStateManager
.appForegroundStateFlow
.onEach {
when (it) {
AppForegroundState.BACKGROUNDED -> handleBackground()
AppForegroundState.FOREGROUNDED -> handleForeground()
}
}
.launchIn(mainScope)
}
private fun handleBackground() {
if (isReceiverRegistered) {
context.unregisterReceiver(restrictionsChangedReceiver)
}
isReceiverRegistered = false
}
private fun handleForeground() {
context.registerReceiver(restrictionsChangedReceiver, intentFilter)
isReceiverRegistered = true
updatePreconfiguredRestrictionSettings()
}
@@ -21,22 +59,65 @@ class RestrictionManagerImpl(
restrictionsManager
.applicationRestrictions
?.takeUnless { it.isEmpty }
?.getString(BASE_ENVIRONMENT_URL_RESTRICTION_KEY)
?.let { setPreconfiguredSettings(it) }
}
private fun setPreconfiguredSettings(bundle: Bundle) {
bundle
.getString(BASE_ENVIRONMENT_URL_RESTRICTION_KEY)
?.let { url -> setPreconfiguredUrl(baseEnvironmentUrl = url) }
}
private fun setPreconfiguredUrl(baseEnvironmentUrl: String) {
environmentRepository.environment = when (baseEnvironmentUrl) {
// If the baseEnvironmentUrl matches the predefined US environment, assume it is the
// default US environment.
Environment.Us.environmentUrlData.base -> Environment.Us
// If the baseEnvironmentUrl matches the predefined EU environment, assume it is the
// default EU environment.
Environment.Eu.environmentUrlData.base -> Environment.Eu
// Otherwise make a custom self-host environment.
else -> Environment.SelfHosted(EnvironmentUrlDataJson(baseEnvironmentUrl))
environmentRepository.environment = when (val current = environmentRepository.environment) {
Environment.Us -> {
when (baseEnvironmentUrl) {
// If the base matches the predefined US environment, leave it alone
Environment.Us.environmentUrlData.base -> current
// If the base does not match the predefined US environment, create a
// self-hosted environment with the new base
else -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
Environment.Eu -> {
when (baseEnvironmentUrl) {
// If the base matches the predefined EU environment, leave it alone
Environment.Eu.environmentUrlData.base -> current
// If the base does not match the predefined EU environment, create a
// self-hosted environment with the new base
else -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
is Environment.SelfHosted -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
/**
* A [BroadcastReceiver] used to listen for [Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED]
* updates.
*
* Note: The `Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED` will only be received if the
* `BroadcastReceiver` is dynamically registered, so this cannot be registered in the manifest.
*/
private inner class RestrictionsChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
updatePreconfiguredRestrictionSettings()
}
}
}
}
private const val BASE_ENVIRONMENT_URL_RESTRICTION_KEY: String = "baseEnvironmentUrl"
/**
* Helper method for creating a new [Environment.SelfHosted] with a new base.
*/
private fun Environment.toSelfHosted(
base: String,
): Environment.SelfHosted =
Environment.SelfHosted(
environmentUrlData = environmentUrlData.copy(base = base),
)

View File

@@ -126,18 +126,9 @@ class AuthenticatorBridgeRepositoryImpl(
account: AccountJson,
decryptedUserKey: String,
): VaultUnlockResult {
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
MissingPropertyException("Private key"),
)
val securityState = authDiskSource
.getAccountKeys(userId = userId)
?.securityState
?.securityState
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val privateKey = authDiskSource
.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(MissingPropertyException("Private key"))
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
@@ -146,11 +137,11 @@ class AuthenticatorBridgeRepositoryImpl(
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
signingKey = null,
securityState = null,
),
)
.flatMap { result ->

View File

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

View File

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

View File

@@ -17,11 +17,6 @@ interface EnvironmentRepository {
*/
val environmentStateFlow: StateFlow<Environment>
/**
* Initializes the [EnvironmentRepository].
*/
fun initialize()
/**
* Stores the current environment for the given [userEmail].
*/

View File

@@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
@@ -21,7 +20,7 @@ import timber.log.Timber
*/
class EnvironmentRepositoryImpl(
private val environmentDiskSource: EnvironmentDiskSource,
private val authDiskSource: AuthDiskSource,
authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
) : EnvironmentRepository {
@@ -45,13 +44,16 @@ class EnvironmentRepositoryImpl(
initialValue = environment,
)
override fun initialize() {
init {
authDiskSource
.userStateFlow
.mapNotNull { userState -> userState?.activeAccount?.settings?.environmentUrlData }
.onEach { environmentUrlDataJson ->
.onEach { userState ->
// If the active account has environment data, set that as the current value.
environmentDiskSource.preAuthEnvironmentUrlData = environmentUrlDataJson
userState
?.activeAccount
?.settings
?.environmentUrlData
?.let { environmentDiskSource.preAuthEnvironmentUrlData = it }
}
.launchIn(scope)
}

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