Compare commits

..

2 Commits

Author SHA1 Message Date
David Perez
b497156302 🍒 PM-25143: Retain intent data on recreate (#5798) 2025-08-27 20:15:00 +00:00
Patrick Honkonen
ab90f5ff95 🍒[PM-25057] Refactor card restriction logic in AutofillCipherProvider (#5791) 2025-08-27 14:43:23 +00:00
490 changed files with 10696 additions and 24856 deletions

6
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,10 +71,10 @@ jobs:
echo "</details>" >> $GITHUB_STEP_SUMMARY
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4 # v1.255.0
with:
bundler-cache: true

View File

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

View File

@@ -22,10 +22,6 @@ on:
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
@@ -58,15 +54,11 @@ jobs:
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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
@@ -78,57 +70,14 @@ jobs:
run: |
BRANCH_NAME="sdlc/sdk-update"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
git switch -c $BRANCH_NAME
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
- name: Get current SDK version
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
SDK_VERSION=$(grep "bitwardenSdk =" gradle/libs.versions.toml | cut -d'"' -f2)
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 version: $SDK_VERSION"
echo "Current SDK git ref: $GIT_REF"
echo "version=$SDK_VERSION" >> $GITHUB_OUTPUT
echo "git_ref=$GIT_REF" >> $GITHUB_OUTPUT
@@ -148,14 +97,14 @@ jobs:
run: |
echo "👀 Committing SDK version update..."
git config user.name "$_BOT_NAME"
git config user.email "$_BOT_EMAIL"
git config user.name "bw-ghapp[bot]"
git config user.email "178206702+bw-ghapp[bot]@users.noreply.github.com"
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
- name: Create Pull Request
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
@@ -172,26 +121,17 @@ jobs:
$CHANGELOG"
EXISTING_PR=$(gh pr list --head $_BRANCH_NAME --base main --state open --json number --jq '.[0].number // empty')
# Use echo -e to interpret escape sequences and pipe to gh pr create
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")
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
echo "🚀 Created PR: $PR_URL"
echo "## 🚀 Created PR: $PR_URL" >> $GITHUB_STEP_SUMMARY
test:
name: Test Update
@@ -203,7 +143,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -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

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

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,6 +27,7 @@ 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
@@ -44,12 +44,17 @@ 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 [DuoCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setDuoCallbackTokenResult] is called.
@@ -105,6 +110,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 +140,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.

View File

@@ -33,8 +33,6 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
import com.bitwarden.network.service.AccountsService
@@ -48,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
@@ -56,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
@@ -79,8 +77,13 @@ 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
@@ -88,26 +91,36 @@ 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
@@ -115,11 +128,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
@@ -129,6 +144,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.Clock
import javax.inject.Singleton
@@ -147,7 +163,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,
@@ -157,13 +172,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
@@ -176,6 +190,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.
@@ -236,6 +268,68 @@ 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 duoTokenChannel = Channel<DuoCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val duoTokenResultFlow: Flow<DuoCallbackTokenResult> = duoTokenChannel.receiveAsFlow()
@@ -264,6 +358,9 @@ class AuthRepositoryImpl(
}
}
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
@@ -282,7 +379,7 @@ class AuthRepositoryImpl(
init {
combine(
userStateManager.hasPendingAccountAdditionStateFlow,
mutableHasPendingAccountAdditionStateFlow,
authDiskSource.userStateFlow,
environmentRepository.environmentStateFlow,
) { hasPendingAddition, userState, environment ->
@@ -301,16 +398,11 @@ 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.
refreshAccessTokenSynchronously(userId = userId)
vaultRepository.sync(forced = true)
}
// This requires the ioScope to ensure that refreshAccessTokenSynchronously
// happens on a background thread
@@ -368,12 +460,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,
@@ -393,7 +489,7 @@ class AuthRepositoryImpl(
override suspend fun deleteAccountWithOneTimePassword(
oneTimePassword: String,
): DeleteAccountResult {
userStateManager.hasPendingAccountDeletion = true
mutableHasPendingAccountDeletionStateFlow.value = true
return accountsService
.deleteAccount(
masterPasswordHash = null,
@@ -405,13 +501,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)
}
@@ -742,17 +838,7 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = {
when (it) {
VerificationCodeResponseJson.Success -> ResendEmailResult.Success
is VerificationCodeResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
onSuccess = { ResendEmailResult.Success },
)
}
?: ResendEmailResult.Error(
@@ -765,17 +851,7 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendNewDeviceOtp(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = {
when (it) {
VerificationOtpResponseJson.Success -> ResendEmailResult.Success
is VerificationOtpResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
onSuccess = { ResendEmailResult.Success },
)
}
?: ResendEmailResult.Error(
@@ -798,7 +874,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
}
@@ -813,7 +889,7 @@ class AuthRepositoryImpl(
authDiskSource.userState = currentUserState.copy(activeUserId = userId)
// Clear any pending account additions
userStateManager.hasPendingAccountAddition = false
hasPendingAccountAddition = false
return SwitchAccountResult.AccountSwitched
}
@@ -1476,6 +1552,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.
*/
@@ -1586,7 +1683,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,
@@ -1625,7 +1722,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,
)
}
@@ -2059,6 +2156,22 @@ class AuthRepositoryImpl(
}
//endregion LoginCommon
/**
* 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,7 +22,6 @@ 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
@@ -53,7 +49,6 @@ object AuthRepositoryModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
configDiskSource: ConfigDiskSource,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
@@ -65,8 +60,8 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
userStateManager: UserStateManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@@ -76,7 +71,6 @@ object AuthRepositoryModule {
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
configDiskSource = configDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatcherManager,
@@ -89,21 +83,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,11 +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.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber
@@ -21,6 +23,7 @@ class OriginManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val privilegedAppRepository: PrivilegedAppRepository,
private val featureFlagManager: FeatureFlagManager,
) : OriginManager {
override suspend fun validateOrigin(
@@ -67,7 +70,10 @@ class OriginManagerImpl(
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
.takeUnless {
it is ValidateOriginResult.Error.PrivilegedAppNotAllowed &&
featureFlagManager.getFeatureFlag(FlagKey.UserManagedPrivilegedApps)
}
?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo)
private suspend fun validatePrivilegedAppSignatureWithGoogleList(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.bitwarden.data.manager
package com.x8bit.bitwarden.data.platform.manager
/**
* Manager for loading native libraries.

View File

@@ -1,4 +1,4 @@
package com.bitwarden.data.manager
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.annotation.OmitFromCoverage
import timber.log.Timber
@@ -7,7 +7,7 @@ import timber.log.Timber
* Primary implementation of [NativeLibraryManager].
*/
@OmitFromCoverage
internal class NativeLibraryManagerImpl : NativeLibraryManager {
class NativeLibraryManagerImpl : NativeLibraryManager {
override fun loadLibrary(libraryName: String): Result<Unit> {
return try {
System.loadLibrary(libraryName)

View File

@@ -15,9 +15,9 @@ import kotlinx.coroutines.flow.Flow
*/
interface PushManager {
/**
* Flow that represents requests intended for full syncs for the user ID provided.
* Flow that represents requests intended for full syncs.
*/
val fullSyncFlow: Flow<String>
val fullSyncFlow: Flow<Unit>
/**
* Flow that represents requests intended to log a user out.
@@ -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
@@ -55,7 +54,7 @@ class PushManagerImpl @Inject constructor(
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
@@ -67,13 +66,13 @@ 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 =
bufferedMutableSharedFlow<SyncSendUpsertData>()
override val fullSyncFlow: SharedFlow<String>
override val fullSyncFlow: SharedFlow<Unit>
get() = mutableFullSyncSharedFlow.asSharedFlow()
override val logoutFlow: SharedFlow<NotificationLogoutData>
@@ -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,24 +189,16 @@ 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,
NotificationType.SYNC_SETTINGS,
NotificationType.SYNC_VAULT,
-> {
json
.decodeFromString<NotificationPayload.SyncNotification>(notification.payload)
.userId
?.let { mutableFullSyncSharedFlow.tryEmit(it) }
mutableFullSyncSharedFlow.tryEmit(Unit)
}
NotificationType.SYNC_FOLDER_CREATE,
@@ -235,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,
@@ -280,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

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

View File

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

View File

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

View File

@@ -74,21 +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()
/**
* A notification payload for syncing a users vault.
*/
@Serializable
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
}

View File

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

View File

@@ -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

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

View File

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

View File

@@ -97,24 +97,6 @@ class VaultDiskSourceImpl(
}
}
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getSelectedCiphers(userId = userId, cipherIds = cipherIds)
return withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Cipher>(
string = entity.cipherJson,
) { Timber.e(it, "Failed to deserialize Cipher in Vault") }
}
}
.awaitAll()
}
}
override suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllTotpCiphers(userId = userId)
return withContext(context = dispatcherManager.default) {
@@ -164,8 +146,6 @@ class VaultDiskSourceImpl(
externalId = collection.externalId,
isReadOnly = collection.isReadOnly,
canManage = collection.canManage,
defaultUserCollectionEmail = collection.defaultUserCollectionEmail,
type = json.encodeToString(collection.type),
),
)
}
@@ -187,8 +167,6 @@ class VaultDiskSourceImpl(
externalId = entity.externalId,
isReadOnly = entity.isReadOnly,
canManage = entity.canManage,
defaultUserCollectionEmail = entity.defaultUserCollectionEmail,
type = json.decodeFromString(entity.type),
)
}
},
@@ -312,8 +290,6 @@ class VaultDiskSourceImpl(
externalId = collection.externalId,
isReadOnly = collection.isReadOnly,
canManage = collection.canManage,
defaultUserCollectionEmail = collection.defaultUserCollectionEmail,
type = json.encodeToString(collection.type),
)
},
)

View File

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

View File

@@ -27,11 +27,10 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
FolderEntity::class,
SendEntity::class,
],
version = 8,
version = 7,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
],
)
@TypeConverters(ZonedDateTimeTypeConverter::class)

View File

@@ -33,10 +33,4 @@ data class CollectionEntity(
@ColumnInfo(name = "manage")
val canManage: Boolean?,
@ColumnInfo(name = "default_user_collection_email")
val defaultUserCollectionEmail: String?,
@ColumnInfo(name = "type", defaultValue = "0")
val type: String,
)

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
import timber.log.Timber
/**
@@ -27,13 +28,21 @@ class Fido2CredentialStoreImpl(
/**
* Return all active ciphers that contain FIDO 2 credentials.
*/
override suspend fun allCredentials(): List<CipherListView> = vaultRepository
.decryptCipherListResultStateFlow
.value
.data
?.successes
.orEmpty()
.filter { it.isActiveWithFido2Credentials }
override suspend fun allCredentials(): List<CipherListView> {
val syncResult = vaultRepository.syncForResult()
if (syncResult is SyncVaultDataResult.Error) {
syncResult.throwable
?.let { throw it }
?: throw IllegalStateException("Sync failed.")
}
return vaultRepository
.decryptCipherListResultStateFlow
.value
.data
?.successes
.orEmpty()
.filter { it.isActiveWithFido2Credentials }
}
/**
* Returns ciphers that contain FIDO 2 credentials for the given [ripId] with the provided
@@ -42,8 +51,15 @@ class Fido2CredentialStoreImpl(
* @param ids Optional list of FIDO 2 credential ID's to find.
* @param ripId Relying Party ID to find.
*/
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> =
vaultRepository
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> {
val syncResult = vaultRepository.syncForResult()
if (syncResult is SyncVaultDataResult.Error) {
syncResult.throwable
?.let { throw it }
?: throw IllegalStateException("Sync failed.")
}
return vaultRepository
.decryptCipherListResultStateFlow
.value
.data
@@ -62,6 +78,7 @@ class Fido2CredentialStoreImpl(
.toCipherViewOrNull()
}
}
}
/**
* Save the provided [cred] to the users vault.
@@ -78,7 +95,7 @@ class Fido2CredentialStoreImpl(
?: vaultRepository.createCipher(decryptedCipherView)
}
.onFailure { throw it }
}
}
/**
* Return a filtered list containing elements that match the given [relyingPartyId] and a

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,8 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UriMatchTypeJson
import com.bitwarden.vault.Attachment
import com.bitwarden.vault.Card
import com.bitwarden.vault.CardListView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherPermissions
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
@@ -28,7 +26,6 @@ import com.bitwarden.vault.Field
import com.bitwarden.vault.FieldType
import com.bitwarden.vault.Identity
import com.bitwarden.vault.Login
import com.bitwarden.vault.LoginListView
import com.bitwarden.vault.LoginUri
import com.bitwarden.vault.PasswordHistory
import com.bitwarden.vault.SecureNote
@@ -106,7 +103,6 @@ fun Cipher.toEncryptedNetworkCipherResponse(
shouldViewPassword = viewPassword,
key = key,
encryptedFor = encryptedFor,
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
@@ -390,7 +386,6 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
creationDate = creationDate.toInstant(),
deletedDate = deletedDate?.toInstant(),
revisionDate = revisionDate.toInstant(),
archivedDate = archivedDate?.toInstant(),
)
/**
@@ -656,55 +651,3 @@ fun EncryptionContext.toEncryptedNetworkCipher(): CipherJsonRequest =
*/
fun EncryptionContext.toEncryptedNetworkCipherResponse(): SyncResponseJson.Cipher =
cipher.toEncryptedNetworkCipherResponse(encryptedFor = encryptedFor)
/**
* Converts a Bitwarden SDK [Cipher] object to a corresponding
* [CipherListView] object with modified field to represent a decryption error instance.
* This allows reuse of existing logic for filtering and grouping ciphers to construct
* the sections in the vault list.
*/
fun Cipher.toFailureCipherListView(): CipherListView =
CipherListView(
id = id,
organizationId = organizationId,
folderId = folderId,
collectionIds = collectionIds,
key = key,
name = name,
subtitle = "",
type = when (type) {
CipherType.LOGIN -> CipherListViewType.Login(
v1 = LoginListView(
fido2Credentials = null,
hasFido2 = false,
username = null,
totp = null,
uris = null,
),
)
CipherType.SECURE_NOTE -> CipherListViewType.SecureNote
CipherType.CARD -> CipherListViewType.Card(
CardListView(
brand = null,
),
)
CipherType.IDENTITY -> CipherListViewType.Identity
CipherType.SSH_KEY -> CipherListViewType.SshKey
},
favorite = favorite,
reprompt = reprompt,
organizationUseTotp = organizationUseTotp,
edit = edit,
permissions = permissions,
viewPassword = viewPassword,
attachments = 0.toUInt(),
hasOldAttachments = false,
localData = null,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
copyableFields = emptyList(),
archivedDate = archivedDate,
)

View File

@@ -1,10 +1,8 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionType
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.CollectionTypeJson
import com.bitwarden.network.model.SyncResponseJson
/**
@@ -20,8 +18,6 @@ fun SyncResponseJson.Collection.toEncryptedSdkCollection(): Collection =
hidePasswords = this.shouldHidePasswords,
readOnly = this.isReadOnly,
manage = this.canManage ?: !this.isReadOnly,
defaultUserCollectionEmail = this.defaultUserCollectionEmail,
type = this.type.toSdkCollectionType(),
)
/**
@@ -32,54 +28,13 @@ fun List<SyncResponseJson.Collection>.toEncryptedSdkCollectionList(): List<Colle
map { it.toEncryptedSdkCollection() }
/**
* Sorts a list of [CollectionView] objects based on a multi-level sorting logic.
*
* The sorting criteria are as follows, in order of precedence:
* 1. Collections of type `DEFAULT_USER_COLLECTION` are placed first.
* 2. All other collections are grouped by their `CollectionType`.
* 3. Within each group, collections are sorted alphabetically by name. For collections of
* type `DEFAULT_USER_COLLECTION`, the corresponding organization's name is used for sorting
* instead of the collection's own name.
*
* This function uses a [SpecialCharWithPrecedenceComparator] for the alphabetical sort.
*
* @param userOrganizations A list of the user's organizations, used to find the name for
* `DEFAULT_USER_COLLECTION` types.
* @return A new list containing the sorted [CollectionView] objects.
* Sorts the data in alphabetical order by name.
*/
fun List<CollectionView>.sortAlphabeticallyByTypeAndOrganization(
userOrganizations: List<SyncResponseJson.Profile.Organization>,
): List<CollectionView> {
@JvmName("toAlphabeticallySortedCollectionList")
fun List<CollectionView>.sortAlphabetically(): List<CollectionView> {
return this.sortedWith(
// DEFAULT_USER_COLLECTION come first
comparator = compareBy<CollectionView> { it.type != CollectionType.DEFAULT_USER_COLLECTION }
// Then sort by other CollectionType ordinals
.thenBy { it.type }
// Finally, sort within each group. For default collections, use the
// organization's name; for others, use the collection's name.
.thenBy(SpecialCharWithPrecedenceComparator) {
if (it.type == CollectionType.DEFAULT_USER_COLLECTION) {
// For default collections, sort by the organization's name
userOrganizations
.find { org -> org.id == it.organizationId }
?.name
?: it.name
} else {
// For other collections, sort by the collection's name
it.name
}
}
// As a final fallback if names are identical, sort by ID to ensure a stable order
.thenBy { it.id },
comparator = { collection1, collection2 ->
SpecialCharWithPrecedenceComparator.compare(collection1.name, collection2.name)
},
)
}
/**
* Converts a [CollectionType] object to a corresponding
* Bitwarden SDK [CollectionTypeJson] object.
*/
fun CollectionTypeJson.toSdkCollectionType(): CollectionType =
when (this) {
CollectionTypeJson.SHARED_COLLECTION -> CollectionType.SHARED_COLLECTION
CollectionTypeJson.DEFAULT_USER_COLLECTION -> CollectionType.DEFAULT_USER_COLLECTION
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,13 +3,10 @@ package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.isValidEmail
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
@@ -55,7 +52,6 @@ class CompleteRegistrationViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val toastManager: ToastManager,
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toCompleteRegistrationArgs()
@@ -148,7 +144,9 @@ class CompleteRegistrationViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
CompleteRegistrationEvent.ShowSnackbar(BitwardenString.email_verified.asText()),
CompleteRegistrationEvent.ShowToast(
message = BitwardenString.email_verified.asText(),
),
)
}
}
@@ -243,7 +241,11 @@ class CompleteRegistrationViewModel @Inject constructor(
private fun handleLoginResult(action: Internal.ReceiveLoginResult) {
clearDialogState()
toastManager.show(messageId = BitwardenString.account_created_success)
sendEvent(
CompleteRegistrationEvent.ShowToast(
message = BitwardenString.account_created_success.asText(),
),
)
authRepository.setOnboardingStatus(
status = OnboardingStatus.NOT_STARTED,
@@ -319,11 +321,8 @@ class CompleteRegistrationViewModel @Inject constructor(
it.copy(
dialog = CompleteRegistrationDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenPlurals.master_password_length_val_message_x
.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
)
}
@@ -500,9 +499,9 @@ sealed class CompleteRegistrationEvent {
data object NavigateBack : CompleteRegistrationEvent()
/**
* Show a snackbar with the given message.
* Show a toast with the given message.
*/
data class ShowSnackbar(
data class ShowToast(
val message: Text,
) : CompleteRegistrationEvent()

View File

@@ -24,12 +24,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
@@ -155,11 +154,7 @@ private fun MinimumCharacterCount(
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text = pluralStringResource(
id = BitwardenPlurals.minimum_characters,
count = minimumCharacterCount,
formatArgs = arrayOf(minimumCharacterCount),
),
text = stringResource(BitwardenString.minimum_characters, minimumCharacterCount),
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.labelSmall,
)

View File

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

View File

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

View File

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

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