Compare commits

..

3 Commits

Author SHA1 Message Date
David Perez
3cec5da78c 🍒 PM-38745: Feat: Update accessibility UI (#7038) 2026-06-08 15:15:58 -05:00
David Perez
4b283af983 🍒 PM-38587: Feat: Add accessibility service disclaimer at startup (#7019) 2026-06-04 18:11:09 -04:00
Patrick Honkonen
d0880d31f2 🍒Update MobilePremiumUpgrade feature flag key to lowercase (#6938) 2026-05-18 16:53:08 +00:00
473 changed files with 7925 additions and 22147 deletions

View File

@@ -1,6 +1,6 @@
---
name: implementing-android-code
version: 0.1.4
version: 0.1.3
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
---
@@ -236,44 +236,6 @@ object ExampleRepositoryModule {
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
- ✅ Use `StateFlow` for continuously observed data
**KDoc on Interfaces vs. Implementations**
KDoc on an interface member describes the **contract** — what the caller can rely on. It must not describe **how** the implementation fulfills that contract. Implementation details (which data source is consulted, what is cached, what is logged, which manager is delegated to, ordering of internal calls, etc.) belong on the `...Impl` member — and only when the *why* is non-obvious from the code itself.
This applies to every interface in the codebase: repositories, managers, data sources, validators, and UI-layer interfaces alike. The rule is the same one CLAUDE.md states for comments in general — describe the contract or the non-obvious *why*, never the *what* a well-named identifier already conveys.
**Wrong** — interface KDoc leaks the implementation:
```kotlin
interface ExampleRepository {
/**
* Fetches data by reading from [ExampleDiskSource] first, falling back to
* [ExampleService] on cache miss and writing the network result back to disk.
*/
suspend fun fetchData(id: String): ExampleResult
}
```
**Right** — interface KDoc describes the contract; implementation details (if needed at all) live on the override:
```kotlin
interface ExampleRepository {
/** Returns the [ExampleData] for [id], or an error result on failure. */
suspend fun fetchData(id: String): ExampleResult
}
class ExampleRepositoryImpl(...) : ExampleRepository {
// No KDoc needed — the disk-then-network pattern is visible in the code.
override suspend fun fetchData(id: String): ExampleResult { ... }
}
```
Red flags that an interface KDoc has drifted into implementation territory:
- Names a concrete collaborator (`...DiskSource`, `...Service`, `...Manager`, `...Impl`)
- Describes ordering of internal calls ("first ... then ...", "falls back to ...")
- Mentions caching, retries, logging, or threading behavior that callers don't depend on
- Restates the method signature in prose ("Suspends until X returns Y")
If callers genuinely depend on a behavior (e.g., "this method is safe to call before vault unlock", "result is cached for the session"), that *is* part of the contract and belongs on the interface. The test: would a different valid implementation be free to change this? If yes, it's implementation detail — strip it.
---
### E. UI Components
@@ -542,9 +504,6 @@ Single-line branches (body fits on the same line as `->`) do **not** need braces
**NEVER call `Instant.now()` or `DateTime.now()` directly**
- Inject `Clock` via Hilt, use `clock.instant()` for testability
**NEVER put implementation details in interface KDoc**
- Interface KDoc describes the contract; concrete collaborators, call ordering, caching, and fallback behavior belong on the `...Impl` override (and only when the *why* is non-obvious). See section D for examples.
---
## Quick Reference

View File

@@ -12,12 +12,12 @@ runs:
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
- name: Configure Ruby
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ inputs.java-version }}

26
.github/label-pr.json vendored
View File

@@ -27,6 +27,32 @@
],
"app:authenticator": [
"authenticator/"
],
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json",
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
"detekt-config.yml"
],
"t:docs": [
"docs/"
],
"t:deps": [
"gradle/"
],
"t:llm": [
".claude/"
]
}
}

View File

@@ -79,7 +79,7 @@ jobs:
- name: Check out repository
if: ${{ !inputs.skip_checkout || false }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: version-info
path: version_info.json

View File

@@ -63,7 +63,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -176,7 +176,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.aab
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
@@ -184,7 +184,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -204,7 +204,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-apk-sha256.txt
path: ./authenticator-android-apk-sha256.txt
@@ -212,7 +212,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-aab-sha256.txt
path: ./authenticator-android-aab-sha256.txt

View File

@@ -48,7 +48,7 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -65,7 +65,7 @@ jobs:
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.testharness.dev-debug.apk
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
@@ -77,7 +77,7 @@ jobs:
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.testharness.dev.apk-sha256.txt
path: ./com.bitwarden.testharness.dev.apk-sha256.txt

View File

@@ -65,7 +65,7 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -192,7 +192,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
@@ -200,7 +200,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
@@ -208,7 +208,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
@@ -216,7 +216,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
@@ -225,7 +225,7 @@ jobs:
# When building variants other than 'prod'
- name: Upload to GitHub Artifacts - dev.apk
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
@@ -263,7 +263,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
@@ -271,7 +271,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
@@ -279,7 +279,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
@@ -287,7 +287,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
@@ -295,7 +295,7 @@ jobs:
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
@@ -343,7 +343,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -424,7 +424,7 @@ jobs:
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
@@ -436,14 +436,14 @@ jobs:
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
@@ -455,7 +455,7 @@ jobs:
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: true

View File

@@ -18,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -47,16 +47,16 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: app-token
with:
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for creating and pushing a new branch
permission-pull-requests: write # for creating pull request
- name: Download translations
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -35,7 +35,7 @@ jobs:
secrets: "CROWDIN-API-TOKEN"
- name: Upload sources
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -73,11 +73,11 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
@@ -169,9 +169,8 @@ jobs:
- name: Enable Publish Github Release Workflow
env:
PRODUCT: ${{ inputs.product }}
DRY_RUN: ${{ inputs.dry-run }}
run: |
if $DRY_RUN ; then
if ${{ inputs.dry-run }} ; then
gh workflow view publish-github-release-bwpm.yml
exit 0
fi

View File

@@ -22,7 +22,7 @@ jobs:
actions: write
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [labeled, opened, ready_for_review, reopened, synchronize]
types: [opened, synchronize, reopened]
permissions: {}

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

View File

@@ -34,7 +34,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false

View File

@@ -53,17 +53,17 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: app-token
with:
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
@@ -204,7 +204,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false

View File

@@ -54,7 +54,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -99,7 +99,7 @@ jobs:
disable_search: true
- name: Upload test reports
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: test-reports-${{ matrix.group }}

View File

@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-partitions (1.1246.0)
aws-sdk-core (3.246.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (1.124.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-s3 (1.221.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -123,10 +123,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-firebase_app_distribution (1.0.0)
fastlane (>= 2.232.0)
google-apis-firebaseappdistribution_v1 (>= 0.9.0)
google-apis-firebaseappdistribution_v1alpha (>= 0.12.0)
fastlane-plugin-firebase_app_distribution (0.10.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.100.0)
@@ -139,10 +138,10 @@ GEM
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-firebaseappdistribution_v1 (0.19.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-firebaseappdistribution_v1alpha (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-firebaseappdistribution_v1 (0.3.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-firebaseappdistribution_v1alpha (0.2.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
@@ -155,7 +154,7 @@ GEM
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)

View File

@@ -9,7 +9,6 @@
android:name="android.hardware.nfc"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.NFC" />
@@ -170,7 +169,6 @@
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:host="bitwarden.pw" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />

View File

@@ -645,10 +645,6 @@
"info": {
"package_name": "com.heytap.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "B2:9A:A0:BB:DC:9F:D9:DE:F5:5D:C5:6E:A7:D7:45:76:D5:84:6C:BC:F5:E5:AB:D3:05:E2:D9:31:9E:4F:42:AE"
},
{
"build": "release",
"cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5"
@@ -660,18 +656,6 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "com.oplus.credential",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E4:98:02:40:95:84:CE:53:15:2A:90:00:82:0A:51:E4:FA:8A:72:3B:7B:CC:26:3E:33:52:40:AC:F1:00:BF:9E"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -43,8 +43,6 @@ import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCooki
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
@@ -97,21 +95,6 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
}
private val stripePortalLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.StripePortalResult(it))
}
private val authTabLaunchers by lazy {
AuthTabLaunchers(
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
stripePortal = stripePortalLauncher,
)
}
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
@@ -135,7 +118,13 @@ class MainActivity : AppCompatActivity() {
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = authTabLaunchers,
authTabLaunchers = AuthTabLaunchers(
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
),
) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
@@ -165,10 +154,6 @@ class MainActivity : AppCompatActivity() {
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
accessibilityDisclosureDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
@@ -257,9 +242,6 @@ class MainActivity : AppCompatActivity() {
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
MainEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
MainEvent.NavigateToAccessibilityDisclosure -> {
navController.navigateToAccessibilityDisclosure()

View File

@@ -37,7 +37,6 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
@@ -82,7 +81,6 @@ class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -177,13 +175,6 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { MainAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
@@ -217,7 +208,6 @@ class MainViewModel @Inject constructor(
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action)
is MainAction.StripePortalResult -> handleStripePortalResult()
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -240,7 +230,6 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.LocalNetworkAccessRequired -> handleLocalNetworkAccessRequired()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
is MainAction.Internal.HasShownAccessibilityDisclaimerUpdate -> {
handleHasShownAccessibilityDisclaimerUpdate(action)
@@ -286,10 +275,6 @@ class MainViewModel @Inject constructor(
)
}
private fun handleStripePortalResult() {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.StripePortal
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -337,10 +322,6 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
@@ -616,14 +597,6 @@ sealed class MainAction {
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the Stripe customer portal flow. The AuthTab does not return a
* payload — closing the tab is the only signal that the user is back in the app.
*/
data class StripePortalResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/
@@ -701,11 +674,6 @@ sealed class MainAction {
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that resize has been requested on the Activity
*/
@@ -749,11 +717,6 @@ sealed class MainEvent {
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Navigate to the accessibility disclosure screen.
*/

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.provider.AppIdProvider
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -114,6 +114,16 @@ interface AuthDiskSource : AppIdProvider {
invalidUnlockAttempts: Int?,
)
/**
* Retrieves a user key using a [userId].
*/
fun getUserKey(userId: String): String?
/**
* Stores a user key using a [userId].
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/
@@ -125,16 +135,34 @@ interface AuthDiskSource : AppIdProvider {
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
/**
* Returns the Wrapped Account Cryptographic State for the given [userId].
* Retrieves a private key using a [userId].
*/
fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState?
@Deprecated(
message = "Use getAccountKeys instead.",
replaceWith = ReplaceWith("getAccountKeys"),
)
fun getPrivateKey(userId: String): String?
/**
* Stores the Wrapped Account Cryptographic State for a given [userId].
* Stores a private key using a [userId].
*/
fun storeAccountCryptographicState(
@Deprecated(
message = "Use storeAccountKeys instead.",
replaceWith = ReplaceWith("storeAccountKeys"),
)
fun storePrivateKey(userId: String, privateKey: String?)
/**
* Returns the profile account keys for the given [userId].
*/
fun getAccountKeys(userId: String): AccountKeysJson?
/**
* Stores the profile account keys for the given [userId].
*/
fun storeAccountKeys(
userId: String,
accountCryptographicState: WrappedAccountCryptographicState?,
accountKeys: AccountKeysJson?,
)
/**

View File

@@ -1,9 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.serializer.SafeMapSerializer
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.bitwarden.network.model.AccountKeysJson
@@ -12,13 +10,10 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.serializer.WrappedAccountCryptographicStateSerializer
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
@@ -57,7 +52,6 @@ private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys"
private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState"
/**
* Primary implementation of [AuthDiskSource].
@@ -95,10 +89,6 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
private val wrappedAccountCryptographicStateSerializer by lazy {
WrappedAccountCryptographicStateSerializer()
}
override var userState: UserStateJson?
get() = getString(key = STATE_KEY)?.let { json.decodeFromStringOrNull(it) }
set(value) {
@@ -117,14 +107,6 @@ class AuthDiskSourceImpl(
// We must migrate the tokens from being stored in the UserState(shared preferences) to
// being stored separately in encrypted shared preferences.
migrateAccountTokens()
// We want to make sure that any left over encrypted user keys are scrubbed from storage
// Since it is no longer supported.
removeLegacyUserKeys()
// We must migrate the Private Key and Account Keys to use the Account Cryptographic state
// from now on.
migrateAccountKeys()
}
override var authenticatorSyncSymmetricKey: ByteArray?
@@ -162,9 +144,11 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storeAccountCryptographicState(userId = userId, accountCryptographicState = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
storeOrganizationKeys(userId = userId, organizationKeys = null)
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricInitVector(userId = userId, iv = null)
@@ -245,6 +229,16 @@ class AuthDiskSourceImpl(
)
}
override fun getUserKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId))
override fun storeUserKey(userId: String, userKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId),
value = userKey,
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
@@ -252,20 +246,29 @@ class AuthDiskSourceImpl(
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
}
override fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState? =
getEncryptedString(key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId))?.let {
json.decodeFromStringOrNull(wrappedAccountCryptographicStateSerializer, it)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
override fun storeAccountCryptographicState(
@Deprecated("Use storeAccountKeys instead.", replaceWith = ReplaceWith("storeAccountKeys"))
override fun storePrivateKey(userId: String, privateKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId),
value = privateKey,
)
}
override fun getAccountKeys(userId: String): AccountKeysJson? =
getEncryptedString(key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId))
?.let { json.decodeFromStringOrNull(it) }
override fun storeAccountKeys(
userId: String,
accountCryptographicState: WrappedAccountCryptographicState?,
accountKeys: AccountKeysJson?,
) {
putEncryptedString(
key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId),
value = accountCryptographicState?.let {
json.encodeToString(wrappedAccountCryptographicStateSerializer, it)
},
key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId),
value = accountKeys?.let { json.encodeToString(it) },
)
}
@@ -482,13 +485,8 @@ class AuthDiskSourceImpl(
getString(key = POLICIES_KEY.appendIdentifier(userId))
?.let {
// The policies are stored as a map.
val policiesMap = json.decodeFromStringOrNull(
deserializer = SafeMapSerializer(
keySerializer = String.serializer(),
valueSerializer = SyncResponseJson.Policy.serializer(),
),
string = it,
)
val policiesMap: Map<String, SyncResponseJson.Policy>? =
json.decodeFromStringOrNull(it)
policiesMap?.values?.toList()
}
@@ -657,35 +655,4 @@ class AuthDiskSourceImpl(
.orEmpty(),
)
}
private fun removeLegacyUserKeys() {
removeWithPrefix(prefix = MASTER_KEY_ENCRYPTION_USER_KEY)
}
private fun migrateAccountKeys() {
userState
?.accounts
.orEmpty()
.values
.forEach { account ->
val userId = account.profile.userId
val accountKeysKey = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId)
val privateKeyKey = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId)
val accountKeys = getEncryptedString(key = accountKeysKey)
?.let { json.decodeFromStringOrNull<AccountKeysJson>(it) }
val privateKey = accountKeys
?.publicKeyEncryptionKeyPair
?.wrappedPrivateKey
?: getString(key = privateKeyKey)
privateKey?.let {
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = accountKeys.toAccountCryptographicState(it),
)
// Remove the Account Keys and Private Key
putEncryptedString(key = accountKeysKey, value = null)
putString(key = privateKeyKey, value = null)
}
}
}
}

View File

@@ -42,11 +42,7 @@ data class AccountJson(
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
* @property hasPremiumPersonally True if the user has personal Premium (i.e., a personal
* subscription not derived from any organization membership).
* @property hasPremiumFromOrganization True if any organization the user is a member of grants
* Premium features. `null` when the value has not yet been synced (e.g., immediately after
* token-based login before the first sync completes).
* @property hasPremium True if the user has a Premium account.
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property forcePasswordResetReason Describes the reason for a forced password reset.
* @property kdfType The KDF type.
@@ -84,10 +80,7 @@ data class AccountJson(
val avatarColorHex: String?,
@SerialName("hasPremiumPersonally")
val hasPremiumPersonally: Boolean?,
@SerialName("hasPremiumFromOrganization")
val hasPremiumFromOrganization: Boolean?,
val hasPremium: Boolean?,
@SerialName("forcePasswordResetReason")
val forcePasswordResetReason: ForcePasswordResetReason?,

View File

@@ -1,85 +0,0 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.serializer
import com.bitwarden.core.WrappedAccountCryptographicState
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Custom [KSerializer] for [WrappedAccountCryptographicState].
*
* Encodes the sealed class with a `"type"` discriminator field:
* - `"v1"`: [WrappedAccountCryptographicState.V1] — wrapped private key only.
* - `"v2"`: [WrappedAccountCryptographicState.V2] — wrapped private key, signing key, signed
* public key, and signed security state.
*/
internal class WrappedAccountCryptographicStateSerializer :
KSerializer<WrappedAccountCryptographicState> {
private val surrogateSerializer = Surrogate.serializer()
override val descriptor: SerialDescriptor = surrogateSerializer.descriptor
override fun deserialize(decoder: Decoder): WrappedAccountCryptographicState =
when (val surrogate = decoder.decodeSerializableValue(surrogateSerializer)) {
is Surrogate.V1 -> {
WrappedAccountCryptographicState.V1(privateKey = surrogate.privateKey)
}
is Surrogate.V2 -> {
WrappedAccountCryptographicState.V2(
privateKey = surrogate.privateKey,
signingKey = surrogate.signingKey,
signedPublicKey = surrogate.signedPublicKey,
securityState = surrogate.securityState,
)
}
}
override fun serialize(encoder: Encoder, value: WrappedAccountCryptographicState) {
val surrogate = when (value) {
is WrappedAccountCryptographicState.V1 -> {
Surrogate.V1(privateKey = value.privateKey)
}
is WrappedAccountCryptographicState.V2 -> {
Surrogate.V2(
privateKey = value.privateKey,
signingKey = value.signingKey,
signedPublicKey = value.signedPublicKey,
securityState = value.securityState,
)
}
}
encoder.encodeSerializableValue(surrogateSerializer, surrogate)
}
}
@Serializable
private sealed class Surrogate {
@Serializable
@SerialName("v1")
data class V1(
@SerialName("privateKey")
val privateKey: String,
) : Surrogate()
@Serializable
@SerialName("v2")
data class V2(
@SerialName("privateKey")
val privateKey: String,
@SerialName("signingKey")
val signingKey: String,
@SerialName("signedPublicKey")
val signedPublicKey: String?,
@SerialName("securityState")
val securityState: String,
) : Surrogate()
}

View File

@@ -11,9 +11,6 @@ import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
@@ -137,13 +134,4 @@ interface AuthSdkSource {
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean>
/**
* Applies the appropriate filters for determining what policies apply to the user.
*/
fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>>
}

View File

@@ -13,19 +13,14 @@ import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import kotlinx.coroutines.withContext
/**
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
@@ -33,7 +28,6 @@ import kotlinx.coroutines.withContext
*/
@Suppress("TooManyFunctions")
class AuthSdkSourceImpl(
private val dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
@@ -48,8 +42,10 @@ class AuthSdkSourceImpl(
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForJitPasswordRegistration(
getClient(userId = userId)
.auth()
.registration()
.postKeysForJitPasswordRegistration(
request = JitMasterPasswordRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
@@ -61,7 +57,6 @@ class AuthSdkSourceImpl(
resetPasswordEnroll = shouldResetPasswordEnroll,
),
)
}
}
override suspend fun postKeysForKeyConnectorRegistration(
@@ -70,13 +65,11 @@ class AuthSdkSourceImpl(
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
}
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
}
}
@@ -87,8 +80,10 @@ class AuthSdkSourceImpl(
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForTdeRegistration(
getClient(userId = userId)
.auth()
.registration()
.postKeysForTdeRegistration(
request = TdeRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
@@ -97,7 +92,6 @@ class AuthSdkSourceImpl(
trustDevice = shouldTrustDevice,
),
)
}
}
override suspend fun postKeysForUserPasswordRegistration(
@@ -107,25 +101,23 @@ class AuthSdkSourceImpl(
masterPasswordHint: String?,
emailVerificationToken: String,
): Result<UserMasterPasswordRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
useClient {
auth().registration().postKeysForUserPasswordRegistration(
request = UserMasterPasswordRegistrationRequest(
email = email,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
organizationUserId = null,
orgInviteToken = null,
orgSponsoredFreeFamilyPlanToken = null,
acceptEmergencyAccessInviteToken = null,
acceptEmergencyAccessId = null,
providerInviteToken = null,
providerUserId = null,
),
)
}
useClient {
auth().registration().postKeysForUserPasswordRegistration(
request = UserMasterPasswordRegistrationRequest(
email = email,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
organizationUserId = null,
orgInviteToken = null,
orgSponsoredFreeFamilyPlanToken = null,
acceptEmergencyAccessInviteToken = null,
acceptEmergencyAccessId = null,
providerInviteToken = null,
providerUserId = null,
),
)
}
}
@@ -229,16 +221,4 @@ class AuthSdkSourceImpl(
)
}
}
override fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>> = runCatchingWithLogs {
globalClient.policies().filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
}
}

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSourceImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
@@ -20,10 +19,8 @@ object AuthSdkModule {
@Provides
@Singleton
fun provideAuthSdkSource(
dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
): AuthSdkSource = AuthSdkSourceImpl(
dispatcherManager = dispatcherManager,
sdkClientManager = sdkClientManager,
)
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
@@ -10,6 +11,14 @@ import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorR
* Manager used to interface with a key connector.
*/
interface KeyConnectorManager {
/**
* Retrieves the master key from the key connector.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Migrates an existing user to use the key connector.
*/

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.bitwarden.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
@@ -15,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorResult
import kotlinx.coroutines.withContext
/**
* The default implementation of the [KeyConnectorManager].
@@ -24,7 +27,17 @@ class KeyConnectorManagerImpl(
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val featureFlagManager: FeatureFlagManager,
private val dispatcherManager: DispatcherManager,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
accountsService.getMasterKeyFromKeyConnector(
url = url,
accessToken = accessToken,
)
override suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
@@ -84,24 +97,26 @@ class KeyConnectorManagerImpl(
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult> =
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
)
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
withContext(dispatcherManager.io) {
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
)
}
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
)
}
}
} else {
legacyMigrateNewUserToKeyConnector(
accountKeys = accountKeys,

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
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
@@ -168,8 +168,8 @@ class UserStateManagerImpl(
private fun existingPolicies(
userId: String,
policyType: PolicyType,
): List<PolicyView> = policyManager.getUserPolicies(
policyType: PolicyTypeJson,
): List<SyncResponseJson.Policy> = policyManager.getUserPolicies(
userId = userId,
type = policyType,
)

View File

@@ -90,12 +90,14 @@ object AuthManagerModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
featureFlagManager = featureFlagManager,
dispatcherManager = dispatcherManager,
)
@Provides

View File

@@ -11,7 +11,6 @@ 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
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@@ -358,11 +357,6 @@ interface AuthRepository :
*/
fun setCookieCallbackResult(result: CookieCallbackResult)
/**
* Retrieves all devices registered to the current user.
*/
suspend fun getDevices(): GetDevicesResult
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View File

@@ -25,9 +25,9 @@ import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.PrevalidateSsoResponseJson
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.bitwarden.network.model.RegisterFinishRequestJson
@@ -38,6 +38,7 @@ import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SendVerificationEmailRequestJson
import com.bitwarden.network.model.SendVerificationEmailResponseJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
@@ -51,8 +52,6 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@@ -74,7 +73,6 @@ 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
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@@ -104,10 +102,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
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.accountKeysJson
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.privateKey
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@@ -131,7 +130,6 @@ 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.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -152,6 +150,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.Clock
import javax.inject.Singleton
@@ -188,7 +187,7 @@ class AuthRepositoryImpl(
private val featureFlagManager: FeatureFlagManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
private val dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
BiometricsEncryptionManager by biometricsEncryptionManager,
@@ -311,7 +310,6 @@ class AuthRepositoryImpl(
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations()
@@ -366,7 +364,7 @@ class AuthRepositoryImpl(
// When the policies for the user have been set, complete the login process.
policyManager
.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
.getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
.onEach { policies ->
val userId = activeUserId ?: return@onEach
@@ -534,13 +532,17 @@ class AuthRepositoryImpl(
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
authDiskSource.storeAccountCryptographicState(
authDiskSource.storeAccountKeys(
userId = userId,
accountCryptographicState = createAccountKeysResponse
.accountKeys
.toAccountCryptographicState(
privateKey = registerTdeKeyResponse.privateKey,
),
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
// have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { response ->
@@ -559,14 +561,15 @@ class AuthRepositoryImpl(
): Result<VaultUnlockResult> {
val userId = profile.userId
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
return authSdkSource
.postKeysForTdeRegistration(
return withContext(dispatcherManager.io) {
authSdkSource.postKeysForTdeRegistration(
userId = userId,
organizationId = orgAutoEnrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
deviceIdentifier = authDiskSource.uniqueAppId,
shouldTrustDevice = shouldTrustDevice,
)
}
.map { response ->
// Clear the 'should trust device' flag, since the SDK trusted the device above.
authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null)
@@ -578,16 +581,25 @@ class AuthRepositoryImpl(
decryptedUserKey = response.userKey,
),
)
.onVaultUnlockSuccess {
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = response.accountCryptographicState,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
.also { result ->
if (result is VaultUnlockResult.Success) {
authDiskSource.storeAccountKeys(
userId = userId,
deviceKey = response.deviceKey,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// Storing the private key here for legacy purposes, the
// `accountKeysJson` stored above will be used for most purposes.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
userId = userId,
deviceKey = response.deviceKey,
)
}
}
}
}
@@ -598,18 +610,25 @@ class AuthRepositoryImpl(
asymmetricalKey: String,
): LoginResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return LoginResult.Error(error = NoActiveUserException())
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
val userId = profile.userId
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
?: return LoginResult.Error(MissingPropertyException("Account Cryptographic State"))
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
checkForVaultUnlockError(
onVaultUnlockError = { error ->
return error.toLoginErrorResult()
},
) {
unlockVault(
accountCryptographicState = accountCryptographicState,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
@@ -649,7 +668,7 @@ class AuthRepositoryImpl(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
else -> LoginResult.Error(error = throwable)
else -> LoginResult.Error(errorMessage = null, error = throwable)
}
},
onSuccess = { it },
@@ -694,7 +713,10 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
override suspend fun login(
email: String,
@@ -712,13 +734,17 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
override suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult {
val response = keyConnectorResponse ?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Key Connector Response"),
)
return handleLoginCommonSuccess(
@@ -946,14 +972,15 @@ class AuthRepositoryImpl(
return RegisterResult.WeakPassword
}
if (featureFlagManager.getFeatureFlag(key = FlagKey.V2EncryptionPassword)) {
return authSdkSource
.postKeysForUserPasswordRegistration(
return withContext(dispatcherManager.io) {
authSdkSource.postKeysForUserPasswordRegistration(
email = email,
salt = email,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
)
}
.fold(
onSuccess = { RegisterResult.Success },
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
@@ -1018,20 +1045,18 @@ class AuthRepositoryImpl(
}
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val profile = authDiskSource
val activeAccount = authDiskSource
.userState
?.activeAccount
?.profile
?: return RemovePasswordResult.Error(error = NoActiveUserException())
val profile = activeAccount.profile
val userId = profile.userId
val userKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
val userKey = authDiskSource
.getUserKey(userId = userId)
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
val keyConnectorUrl = organizations
.find {
it.isKeyConnectorEnabled &&
it.shouldUseKeyConnector &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
@@ -1196,6 +1221,9 @@ class AuthRepositoryImpl(
keys = null,
),
)
.onSuccess {
authDiskSource.storeUserKey(userId = userId, userKey = response.newKey)
}
.map { response.passwordHash }
}
.flatMap { masterPasswordHash ->
@@ -1249,21 +1277,31 @@ class AuthRepositoryImpl(
.map { orgKeys -> enrollStatus to orgKeys }
}
.flatMap { (enrollStatus, orgKeys) ->
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
withContext(dispatcherManager.io) {
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
}
}
.onSuccess { response ->
authDiskSource.storeAccountCryptographicState(
authDiskSource.storeAccountKeys(
userId = userId,
accountCryptographicState = response.accountCryptographicState,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = response.masterPasswordUnlock,
@@ -1320,11 +1358,16 @@ class AuthRepositoryImpl(
),
)
.onSuccess {
authDiskSource.storeAccountCryptographicState(
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(
userId = userId,
accountCryptographicState = WrappedAccountCryptographicState.V1(
privateKey = response.keys.private,
),
privateKey = response.keys.private,
)
authDiskSource.storeUserKey(
userId = userId,
userKey = response.encryptedUserKey,
)
}
.map { response.masterPasswordHash }
@@ -1418,20 +1461,6 @@ class AuthRepositoryImpl(
mutableCookieCallbackResultFlow.tryEmit(result)
}
override suspend fun getDevices(): GetDevicesResult =
devicesService
.getDevices()
.fold(
onFailure = { GetDevicesResult.Error },
onSuccess = { response ->
GetDevicesResult.Success(
devices = response.devices.map { json ->
json.toDeviceInfo(currentDeviceIdentifier = authDiskSource.uniqueAppId)
},
)
},
)
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(
@@ -1471,12 +1500,7 @@ class AuthRepositoryImpl(
)
override suspend fun validatePassword(password: String): ValidatePasswordResult {
val profile = authDiskSource
.userState
?.activeAccount
?.profile
?: return ValidatePasswordResult.Error(error = NoActiveUserException())
val userId = profile.userId
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
return authDiskSource
.getMasterPasswordHash(userId = userId)
?.let { masterPasswordHash ->
@@ -1492,10 +1516,8 @@ class AuthRepositoryImpl(
)
}
?: run {
val encryptedKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
val encryptedKey = authDiskSource
.getUserKey(userId)
?: return ValidatePasswordResult.Error(MissingPropertyException("UserKey"))
vaultSdkSource
.validatePasswordUserKey(
@@ -1513,8 +1535,8 @@ class AuthRepositoryImpl(
onSuccess = { ValidatePasswordResult.Success(isValid = true) },
onFailure = {
// We currently assume that all errors are caused by the user entering
// an invalid password, this is not necessarily the case, but we have
// no way to differentiate between the different errors.
// an invalid password, this is not necessarily the case but we have no
// way to differentiate between the different errors.
ValidatePasswordResult.Success(isValid = false)
},
)
@@ -1668,7 +1690,7 @@ class AuthRepositoryImpl(
*/
private suspend fun passwordPassesPolicies(
password: String,
policies: List<PolicyView>,
policies: List<SyncResponseJson.Policy>,
): Boolean {
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
@@ -1778,7 +1800,10 @@ class AuthRepositoryImpl(
LoginResult.UnofficialServerError
}
else -> LoginResult.Error(error = throwable)
else -> LoginResult.Error(
errorMessage = null,
error = throwable,
)
}
},
onSuccess = { loginResponse ->
@@ -1842,14 +1867,6 @@ class AuthRepositoryImpl(
)
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
@@ -1882,7 +1899,6 @@ class AuthRepositoryImpl(
// If a new KeyConnector user is logging in for the first time,
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
keyConnectorResponse = loginResponse
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
@@ -1924,7 +1940,16 @@ class AuthRepositoryImpl(
passwordsToCheckMap.put(userId, it)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true
authDiskSource.userState = userStateJson
password?.let {
// Automatically update kdf to minimums after password unlock and userState update
@@ -1936,16 +1961,22 @@ class AuthRepositoryImpl(
}
}
}
loginResponse.privateKeyOrNull()?.let { privateKey ->
// Only set the value if the private key is present, since we may have set
// the value already when we completed the key connector conversion.
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
)
loginResponse.key?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
// We continue to store the private key for backwards compatibility. Key connector
// conversion still relies on the private key.
loginResponse.privateKeyOrNull()?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
loginResponse.accountKeys?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storeAccountKeys(userId = userId, accountKeys = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
@@ -2034,16 +2065,28 @@ class AuthRepositoryImpl(
null
} else if (key != null && privateKey != null) {
// This is a returning user who should already have the key connector setup
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnectorUrl(
keyConnectorManager
.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
keyConnectorKeyWrappedUserKey = key,
),
)
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it },
)
} else {
// This is a new user who needs to set up the key connector
val userId = profile.userId
@@ -2060,21 +2103,35 @@ class AuthRepositoryImpl(
organizationIdentifier = orgIdentifier,
)
.map { keyConnector ->
val accountCryptographicState = keyConnector.accountCryptographicState
this
.unlockVault(
accountCryptographicState = accountCryptographicState,
accountCryptographicState = keyConnector.accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnector.masterKey,
userKey = keyConnector.encryptedUserKey,
),
)
.onVaultUnlockSuccess {
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = accountCryptographicState,
)
.also { result ->
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the
// userKey and privateKey we now have since it didn't exist on the
// loginResponse.
authDiskSource.storeUserKey(
userId = userId,
userKey = keyConnector.encryptedUserKey,
)
// We continue to store the private key for backwards compatibility
// since key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = keyConnector.privateKey,
)
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = loginResponse.accountKeys,
)
}
}
}
.fold(
@@ -2149,8 +2206,8 @@ class AuthRepositoryImpl(
),
)
// We are purposely not storing the master password hash here since it is not
// formatted in a manner that we can use. We will store it properly the next
// time the user enters their master password, and it is validated.
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
}
}
@@ -2204,6 +2261,7 @@ class AuthRepositoryImpl(
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
@@ -2233,6 +2291,10 @@ class AuthRepositoryImpl(
deviceProtectedUserKey = encryptedUserKey,
),
)
if (vaultUnlockResult is VaultUnlockResult.Success) {
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
return vaultUnlockResult
}
@@ -2255,7 +2317,7 @@ class AuthRepositoryImpl(
// unlock the vault for organization data after receiving the sync response if this
// data is currently absent. These keys may be present during certain multi-phase login
// processes or if we needed to delete the user's token due to an encrypted data
// corruption issue, and they are forced to log back in.
// corruption issue and they are forced to log back in.
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
)
}

View File

@@ -1,32 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.model
import android.os.Parcelable
import com.bitwarden.network.model.DeviceType
import kotlinx.parcelize.Parcelize
import java.time.Instant
/**
* Domain model for a device registered to the current user.
*
* @property id The unique identifier of the device.
* @property name The name of the device.
* @property identifier The unique device install identifier of the device.
* @property type The type of the device.
* @property isTrusted Whether this device is trusted.
* @property creationDate The date and time on which this device was created.
* @property lastActivityDate The date and time of the device's last activity, if available.
* @property pendingAuthRequest The pending auth request for this device, if any.
* @property isCurrentDevice If this is the current device being used.
*/
@Parcelize
data class DeviceInfo(
val id: String,
val name: String,
val identifier: String,
val type: DeviceType,
val isTrusted: Boolean,
val creationDate: Instant,
val lastActivityDate: Instant?,
val pendingAuthRequest: DevicePendingAuthRequest?,
val isCurrentDevice: Boolean,
) : Parcelable

View File

@@ -1,17 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
/**
* Domain model for a pending auth request associated with a device.
*
* @property id The unique identifier of the pending auth request.
* @property creationDate The date and time on which this auth request was created.
*/
@Parcelize
data class DevicePendingAuthRequest(
val id: String,
val creationDate: Instant,
) : Parcelable

View File

@@ -1,16 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of retrieving all devices registered to the current user.
*/
sealed class GetDevicesResult {
/**
* Contains the list of [DeviceInfo] for the current user's registered devices.
*/
data class Success(val devices: List<DeviceInfo>) : GetDevicesResult()
/**
* There was an error retrieving the devices.
*/
data object Error : GetDevicesResult()
}

View File

@@ -1,7 +1,5 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of logging in.
*/
@@ -32,8 +30,8 @@ sealed class LoginResult {
* There was an error logging in.
*/
data class Error(
val errorMessage: String?,
val error: Throwable?,
val errorMessage: String? = error?.userFriendlyMessage,
) : LoginResult()
/**

View File

@@ -15,5 +15,5 @@ fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(error = this.error)
-> LoginResult.Error(errorMessage = null, error = this.error)
}

View File

@@ -9,7 +9,7 @@ import com.bitwarden.network.model.OrganizationType
* @property name The name of the organization (if applicable).
* @property shouldManageResetPassword Indicates that this user has the permission to manage their
* own password.
* @property isKeyConnectorEnabled Indicates that the organization uses a key connector.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
* @property keyConnectorUrl The key connector domain (if applicable).
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
@@ -20,7 +20,7 @@ data class Organization(
val id: String,
val name: String,
val shouldManageResetPassword: Boolean,
val isKeyConnectorEnabled: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,

View File

@@ -42,12 +42,7 @@ data class UserState(
* @property name The user's name (if applicable).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property environment The [Environment] associated with the user's account.
* @property isPremium `true` if the account has a Premium membership from any source (personal
* subscription or organization-granted).
* @property isPremiumFromSelf `true` if the account has a personal Premium subscription. This
* is `false` for users whose only Premium access is granted by an organization they are a
* member of. Use this when behavior should be gated on the user's own subscription, not on
* organization-granted Premium.
* @property isPremium `true` if the account has a Premium membership.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
@@ -71,7 +66,6 @@ data class UserState(
val avatarColorHex: String,
val environment: Environment,
val isPremium: Boolean,
val isPremiumFromSelf: Boolean,
val isLoggedIn: Boolean,
val isVaultUnlocked: Boolean,
val needsPasswordReset: Boolean,
@@ -119,7 +113,6 @@ data class UserState(
avatarColorHex = "".toHexColorRepresentation(),
environment = Environment.Us,
isPremium = false,
isPremiumFromSelf = false,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
/**
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
@@ -14,20 +15,9 @@ import com.bitwarden.network.model.AccountKeysJson
*/
fun AccountKeysJson?.toAccountCryptographicState(
privateKey: String,
): WrappedAccountCryptographicState {
val securityState = this?.securityState?.securityState
val signingKey = this?.signatureKeyPair?.wrappedSigningKey
val signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey
return if (signingKey != null && securityState != null && signedPublicKey != null) {
WrappedAccountCryptographicState.V2(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
)
} else {
WrappedAccountCryptographicState.V1(
privateKey = privateKey,
)
}
}
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = this?.securityState?.securityState,
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
)

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.network.model.OrganizationStatusType
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.repository.model.UserAccountTokens
@@ -28,7 +27,6 @@ val AuthDiskSource.userOrganizationsList: List<UserOrganizations>
userId = userId,
organizations = this
.getOrganizations(userId = userId)
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations(),
)
@@ -50,15 +48,10 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
.map { (userId, _) ->
this
.getOrganizationsFlow(userId = userId)
.map { organizations ->
.map {
UserOrganizations(
userId = userId,
organizations = organizations
?.filter {
it.status == OrganizationStatusType.CONFIRMED
}
.orEmpty()
.toOrganizations(),
organizations = it.orEmpty().toOrganizations(),
)
}
},

View File

@@ -1,26 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.network.model.DeviceResponseJson
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
import com.x8bit.bitwarden.data.auth.repository.model.DevicePendingAuthRequest
/**
* Maps the given [DeviceResponseJson] to a [DeviceInfo].
*/
fun DeviceResponseJson.toDeviceInfo(currentDeviceIdentifier: String): DeviceInfo =
DeviceInfo(
id = id,
name = name,
identifier = identifier,
type = type,
isTrusted = isTrusted,
creationDate = creationDate,
lastActivityDate = lastActivityDate,
pendingAuthRequest = devicePendingAuthRequest?.let {
DevicePendingAuthRequest(
id = it.id,
creationDate = it.creationDate,
)
},
isCurrentDevice = identifier == currentDeviceIdentifier,
)

View File

@@ -31,8 +31,7 @@ fun GetTokenResponseJson.Success.toUserState(
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = jwtTokenData.hasPremium,
hasPremiumFromOrganization = null,
hasPremium = jwtTokenData.hasPremium,
forcePasswordResetReason = this.toForcePasswordResetReason(),
kdfType = this.kdfType,
kdfIterations = this.kdfIterations,

View File

@@ -1,24 +1,11 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.network.model.MemberDecryptionType
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.ProductTierType
import com.bitwarden.network.model.ProviderType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.organizations.Permissions
import com.bitwarden.organizations.ProfileOrganization
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import kotlinx.serialization.json.Json
import com.bitwarden.organizations.MemberDecryptionType as SdkMemberDecryptionType
import com.bitwarden.organizations.ProductTierType as SdkProductTierType
import com.bitwarden.organizations.ProviderType as SdkProviderType
private val JSON = Json {
ignoreUnknownKeys = true
@@ -34,7 +21,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
Organization(
id = this.id,
name = it,
isKeyConnectorEnabled = this.isKeyConnectorEnabled,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
@@ -52,164 +39,28 @@ fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organiza
this.mapNotNull { it.toOrganization() }
/**
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [ProfileOrganization]s.
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
*/
@Suppress("MaxLineLength")
fun List<SyncResponseJson.Profile.Organization>.toSdkProfileOrganizations(): List<ProfileOrganization> =
this.mapNotNull { it.toSdkProfileOrganization() }
val SyncResponseJson.Policy.policyInformation: PolicyInformation?
get() = data?.toString()?.let {
/**
* Maps the given [SyncResponseJson.Profile.Organization] to a [ProfileOrganization] or `null` if
* the [SyncResponseJson.Profile.Organization.name] is not present.
*/
@Suppress("LongMethod")
private fun SyncResponseJson.Profile.Organization.toSdkProfileOrganization(): ProfileOrganization? =
this.name?.let {
ProfileOrganization(
id = this.id,
name = it,
status = this.status.toSdkOrganizationUserStatusType(),
type = this.type.toSdkOrganizationUserType(),
enabled = this.isEnabled,
usePolicies = this.shouldUsePolicies,
useGroups = this.shouldUseGroups,
useDirectory = this.shouldUseDirectory,
useEvents = this.shouldUseEvents,
useTotp = this.shouldUseTotp,
use2fa = this.use2fa,
useApi = this.shouldUseApi,
useSso = this.useSso,
useOrganizationDomains = this.useOrganizationDomains,
useKeyConnector = this.shouldUseKeyConnector,
useScim = this.useScim,
useCustomPermissions = this.useCustomPermissions,
useResetPassword = this.useResetPassword,
useSecretsManager = this.useSecretsManager,
usePasswordManager = this.usePasswordManager,
useActivateAutofillPolicy = this.useActivateAutofillPolicy,
useAutomaticUserConfirmation = this.useAutomaticUserConfirmation,
selfHost = this.isSelfHost,
usersGetPremium = this.shouldUsersGetPremium,
seats = this.seats,
maxCollections = this.maxCollections,
maxStorageGb = this.maxStorageGb,
ssoBound = this.ssoBound,
identifier = this.identifier,
permissions = this.permissions.toSdkPermissions(),
resetPasswordEnrolled = this.resetPasswordEnrolled,
userId = this.userId,
organizationUserId = this.organizationUserId,
hasPublicAndPrivateKeys = this.hasPublicAndPrivateKeys,
providerId = this.providerId,
providerName = this.providerName,
providerType = this.providerType?.toSdkProviderType(),
isProviderUser = this.isProviderUser,
isMember = this.isMember,
familySponsorshipFriendlyName = this.familySponsorshipFriendlyName,
familySponsorshipAvailable = this.familySponsorshipAvailable,
productTierType = this.productTierType.toSdkProductTierType(),
keyConnectorEnabled = this.isKeyConnectorEnabled,
keyConnectorUrl = this.keyConnectorUrl,
familySponsorshipLastSyncDate = this.familySponsorshipLastSyncDate,
familySponsorshipValidUntil = this.familySponsorshipValidUntil,
familySponsorshipToDelete = this.familySponsorshipToDelete,
accessSecretsManager = this.accessSecretsManager,
limitCollectionCreation = this.limitCollectionCreation,
limitCollectionDeletion = this.limitCollectionDeletion,
limitItemDeletion = this.limitItemDeletion,
allowAdminAccessToAllCollectionItems = this.allowAdminAccessToAllCollectionItems,
userIsManagedByOrganization = this.userIsClaimedByOrganization,
useAccessIntelligence = this.useAccessIntelligence,
useAdminSponsoredFamilies = this.useAdminSponsoredFamilies,
useDisableSmAdsForUsers = this.useDisableSmAdsForUsers,
isAdminInitiated = this.isAdminInitiated,
ssoEnabled = this.ssoEnabled,
ssoMemberDecryptionType = this.ssoMemberDecryptionType?.toSdkMemberDecryptionType(),
usePhishingBlocker = this.usePhishingBlocker,
useMyItems = this.useMyItems,
)
}
/**
* Convert the JSON data of the [PolicyView] object into [PolicyInformation] data.
*/
val PolicyView.policyInformation: PolicyInformation?
get() = data?.let {
when (type) {
PolicyType.MASTER_PASSWORD -> {
PolicyTypeJson.MASTER_PASSWORD -> {
JSON.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyType.PASSWORD_GENERATOR -> {
PolicyTypeJson.PASSWORD_GENERATOR -> {
JSON.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
JSON.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
PolicyType.SEND_OPTIONS -> {
PolicyTypeJson.SEND_OPTIONS -> {
JSON.decodeFromStringOrNull<PolicyInformation.SendOptions>(it)
}
else -> null
}
}
private fun SyncResponseJson.Profile.Permissions.toSdkPermissions(): Permissions =
Permissions(
accessEventLogs = this.accessEventLogs,
accessImportExport = this.accessImportExport,
accessReports = this.accessReports,
createNewCollections = this.createNewCollections,
editAnyCollection = this.editAnyCollection,
deleteAnyCollection = this.deleteAnyCollection,
manageGroups = this.manageGroups,
manageSso = this.manageSso,
managePolicies = this.shouldManagePolicies,
manageUsers = this.manageUsers,
manageResetPassword = this.shouldManageResetPassword,
manageScim = this.manageScim,
)
private fun OrganizationStatusType.toSdkOrganizationUserStatusType(): OrganizationUserStatusType =
when (this) {
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
}
private fun OrganizationType.toSdkOrganizationUserType(): OrganizationUserType =
when (this) {
OrganizationType.OWNER -> OrganizationUserType.OWNER
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
OrganizationType.USER -> OrganizationUserType.USER
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
}
private fun ProviderType.toSdkProviderType(): SdkProviderType =
when (this) {
ProviderType.MSP -> SdkProviderType.MSP
ProviderType.RESELLER -> SdkProviderType.RESELLER
ProviderType.BUSINESS_UNIT -> SdkProviderType.BUSINESS_UNIT
}
private fun ProductTierType.toSdkProductTierType(): SdkProductTierType =
when (this) {
ProductTierType.FREE -> SdkProductTierType.FREE
ProductTierType.FAMILIES -> SdkProductTierType.FAMILIES
ProductTierType.TEAMS -> SdkProductTierType.TEAMS
ProductTierType.ENTERPRISE -> SdkProductTierType.ENTERPRISE
ProductTierType.TEAMS_STARTER -> SdkProductTierType.TEAMS_STARTER
}
private fun MemberDecryptionType.toSdkMemberDecryptionType(): SdkMemberDecryptionType =
when (this) {
MemberDecryptionType.MASTER_PASSWORD -> SdkMemberDecryptionType.MASTER_PASSWORD
MemberDecryptionType.KEY_CONNECTOR -> SdkMemberDecryptionType.KEY_CONNECTOR
MemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION -> {
SdkMemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION
}
}

View File

@@ -5,10 +5,9 @@ import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -34,10 +33,7 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
val profile = account.profile
val updatedUserDecryptionOptions = profile
.userDecryptionOptions
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
?.copy(hasMasterPassword = false)
?: UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
@@ -71,10 +67,7 @@ fun UserStateJson.toUpdatedUserStateJson(
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
@@ -90,8 +83,7 @@ fun UserStateJson.toUpdatedUserStateJson(
.copy(
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremiumPersonally = syncProfile.isPremium,
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
@@ -199,7 +191,7 @@ fun UserStateJson.toUserState(
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
getUserPolicies: (userId: String, policy: PolicyType) -> List<PolicyView>,
getUserPolicies: (userId: String, policy: PolicyTypeJson) -> List<SyncResponseJson.Policy>,
): UserState =
UserState(
activeUserId = this.activeUserId,
@@ -242,15 +234,15 @@ fun UserStateJson.toUserState(
val hasPersonalOwnershipRestrictedOrg = getUserPolicies(
userId,
PolicyType.ORGANIZATION_DATA_OWNERSHIP,
PolicyTypeJson.PERSONAL_OWNERSHIP,
)
.any { it.enabled }
.any { it.isEnabled }
val hasPersonalVaultExportRestrictedOrg = getUserPolicies(
userId,
PolicyType.DISABLE_PERSONAL_VAULT_EXPORT,
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT,
)
.any { it.enabled }
.any { it.isEnabled }
UserState.Account(
userId = userId,
@@ -261,9 +253,7 @@ fun UserStateJson.toUserState(
.settings
.environmentUrlData
.toEnvironmentUrlsOrDefault(),
isPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true,
isPremiumFromSelf = profile.hasPremiumPersonally == true,
isPremium = profile.hasPremium == true,
isLoggedIn = userAccountTokens
.find { it.userId == userId }
?.isLoggedIn == true,

View File

@@ -6,7 +6,6 @@ package com.x8bit.bitwarden.data.autofill.accessibility.model
data class Browser(
val packageName: String,
val possibleUrlFieldIds: List<String>,
val possibleUrlSemanticIds: List<String> = emptyList(),
val urlExtractor: (String) -> String? = { it },
) {
constructor(
@@ -16,7 +15,6 @@ data class Browser(
) : this(
packageName = packageName,
possibleUrlFieldIds = listOf(urlFieldId),
possibleUrlSemanticIds = emptyList(),
urlExtractor = urlExtractor,
)
}

View File

@@ -43,34 +43,22 @@ class AccessibilityParserImpl(
return browser
.possibleUrlFieldIds
.flatMap { viewId ->
rootNode.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId")
}
.ifEmpty {
browser
.possibleUrlSemanticIds
.flatMap { semanticId ->
// Semantic IDs are exposed as viewIdResourceName via testTagsAsResourceId
// and cannot be found via findAccessibilityNodeInfosByViewId on Firefox.
accessibilityNodeInfoManager.findAccessibilityNodeInfoList(rootNode) {
it.viewIdResourceName == semanticId
}
}
}
.firstNotNullOfOrNull { node ->
val urlText = node.text?.toString()?.takeIf { it.isNotEmpty() }
?: node.contentDescription?.toString()?.takeIf { it.isNotEmpty() }
?: return@firstNotNullOfOrNull null
browser
.urlExtractor(urlText)
?.trim()
?.let { rawUrl ->
if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) {
"https://$rawUrl"
} else {
rawUrl
}
rootNode
.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId")
.map { accessibilityNodeInfo ->
browser
.urlExtractor(accessibilityNodeInfo.text.toString())
?.trim()
?.let { rawUrl ->
if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) {
"https://$rawUrl"
} else {
rawUrl
}
}
}
}
.firstOrNull()
?.toUriOrNull()
}
}

View File

@@ -2,15 +2,6 @@ package com.x8bit.bitwarden.data.autofill.accessibility.util
import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser
/**
* URL extractor for Mozilla browsers whose toolbar exposes the URL via [contentDescription]
* rather than [text]. The content description format is " $url. Search or enter address".
* Falls back to [text] for builds where the URL is still exposed via [text].
*/
private val mozillaUrlExtractor: (String) -> String? = { text ->
text.trim().split(" ").firstOrNull()?.trimEnd('.')?.takeIf { it.isNotEmpty() }
}
/**
* Determines if the [String] receiver is a package name for a supported browser and returns that
* [Browser] if it is a match.
@@ -45,21 +36,14 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(packageName = "com.cookiegames.smartcookie", urlFieldId = "search"),
Browser(
packageName = "com.cookiejarapps.android.smartcookieweb",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "com.duckduckgo.mobile.android", urlFieldId = "omnibarTextInput"),
Browser(packageName = "com.ecosia.android", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome_dev", urlFieldId = "url_bar"),
// "com.google.android.captiveportallogin": URL displayed in ActionBar subtitle without viewId
Browser(
packageName = "com.iode.firefox",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "com.iode.firefox", urlFieldId = "mozac_browser_toolbar_url_view"),
Browser(packageName = "com.jamal2367.styx", urlFieldId = "search"),
Browser(packageName = "com.kiwibrowser.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.kiwibrowser.browser.dev", urlFieldId = "url_bar"),
@@ -83,12 +67,7 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(
packageName = "com.qwant.liberty",
// 2nd = Legacy (before v4)
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "com.rainsee.create", urlFieldId = "search_box"),
Browser(packageName = "com.sec.android.app.sbrowser", urlFieldId = "location_bar_edit_text"),
@@ -123,9 +102,7 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(packageName = "idm.internet.download.manager.plus", urlFieldId = "search"),
Browser(
packageName = "io.github.forkmaintainers.iceraven",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "mark.via", urlFieldId = "am,an"),
Browser(packageName = "mark.via.gp", urlFieldId = "as"),
@@ -152,155 +129,78 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(
packageName = "org.gnu.icecat",
// 2nd = Anticipation
possibleUrlFieldIds = listOf(
"url_bar_title",
"mozac_browser_toolbar_url_view",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("url_bar_title", "mozac_browser_toolbar_url_view"),
),
Browser(
packageName = "org.ironfoxoss.ironfox",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.ironfoxoss.ironfox.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.fenix",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fenix.nightly",
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
urlFieldId = "mozac_browser_toolbar_url_view",
),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fennec_aurora",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.fennec_fdroid",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.firefox",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.firefox_beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.mozilla.focus",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.focus.beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.focus.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.klar",
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
),
Browser(
packageName = "org.mozilla.reference.browser",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
urlFieldId = "mozac_browser_toolbar_url_view",
),
Browser(packageName = "org.mozilla.rocket", urlFieldId = "display_url"),
Browser(
packageName = "org.torproject.torbrowser",
// 2nd = Legacy (before v10.0.3)
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(
packageName = "org.torproject.torbrowser_alpha",
// 2nd = Legacy (before v10.0a8)
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
),
Browser(packageName = "org.ungoogled.chromium.extensions.stable", urlFieldId = "url_bar"),
Browser(packageName = "org.ungoogled.chromium.stable", urlFieldId = "url_bar"),

View File

@@ -63,16 +63,13 @@ class BrowserThirdPartyAutofillManagerImpl(
var thirdPartyEnabled = false
val isThirdPartyAvailable = cursor
?.use {
if (it.moveToFirst()) {
thirdPartyEnabled = it
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
.takeUnless { columnIndex -> columnIndex == -1 }
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
?: false
true
} else {
false
}
it.moveToFirst()
thirdPartyEnabled = it
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
.takeUnless { columnIndex -> columnIndex == -1 }
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
?: false
true
}
?: false
return BrowserThirdPartyAutoFillData(

View File

@@ -6,7 +6,7 @@ import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.policies.PolicyType
import com.bitwarden.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.SaveInfoBuilder
@@ -80,7 +80,7 @@ class AutofillProcessorImpl(
return
}
if (policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any()) {
if (policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).any()) {
saveCallback.onSuccess()
return
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.autofill.provider
import com.bitwarden.policies.PolicyType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherRepromptType
@@ -58,7 +58,7 @@ class AutofillCipherProviderImpl(
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
val cipherListViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
val organizationIdsWithCardTypeRestrictions = policyManager
.getActivePolicies(PolicyType.RESTRICTED_ITEM_TYPES)
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.map { it.organizationId }
return cipherListViews
.mapNotNull { cipherListView ->

View File

@@ -4,6 +4,7 @@ import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManagerImpl
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
@@ -13,7 +14,6 @@ import com.x8bit.bitwarden.data.billing.repository.BillingRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@@ -54,21 +54,21 @@ object BillingModule {
@Singleton
fun providePremiumStateManager(
authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
billingRepository: BillingRepository,
settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
environmentRepository: EnvironmentRepository,
pushManager: PushManager,
clock: Clock,
dispatcherManager: DispatcherManager,
): PremiumStateManager = PremiumStateManagerImpl(
authDiskSource = authDiskSource,
authRepository = authRepository,
billingRepository = billingRepository,
settingsDiskSource = settingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
environmentRepository = environmentRepository,
pushManager = pushManager,
clock = clock,
dispatcherManager = dispatcherManager,

View File

@@ -1,7 +1,5 @@
package com.x8bit.bitwarden.data.billing.manager
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import kotlinx.coroutines.flow.StateFlow
/**
@@ -28,34 +26,6 @@ interface PremiumStateManager {
*/
val isUpgradedToPremiumCardEligibleFlow: StateFlow<Boolean>
/**
* Emits `true` when the active user is eligible to see the Plan row in Settings, or `false`
* otherwise.
*/
val isPlanRowEligibleFlow: StateFlow<Boolean>
/**
* Emits the active user's latest [SubscriptionStatusState].
*/
val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState>
/**
* Emits the active user's current [UpgradeLifecycleState].
*/
val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState>
/**
* Emits whether the current state should be treated as self-hosted for premium upgrade
* gating. Reactive equivalent of [isSelfHosted].
*/
val isSelfHostedFlow: StateFlow<Boolean>
/**
* `true` when the current state should be treated as self-hosted for premium upgrade
* gating, or `false` otherwise.
*/
val isSelfHosted: Boolean
/**
* Returns `true` when the in-app upgrade flow is available, or `false` otherwise.
*/
@@ -72,10 +42,4 @@ interface PremiumStateManager {
* never re-appears for that user.
*/
fun dismissUpgradedToPremiumCard()
/**
* Marks the active user as having a Premium upgrade in flight (Stripe checkout completed
* but the server has not yet flipped `isPremium`).
*/
fun markPremiumUpgradePending(userId: String)
}

View File

@@ -3,37 +3,27 @@ package com.x8bit.bitwarden.data.billing.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.scanPairs
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
@@ -43,14 +33,14 @@ import java.time.Instant
/**
* Default implementation of [PremiumStateManager].
*/
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList")
class PremiumStateManagerImpl(
private val authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
private val billingRepository: BillingRepository,
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
private val environmentRepository: EnvironmentRepository,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
@@ -58,74 +48,10 @@ class PremiumStateManagerImpl(
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val subscriptionRefreshTriggerFlow =
MutableSharedFlow<Unit>(replay = 0, extraBufferCapacity = 1)
/**
* Keyed on the active user's id so a logout/switch retriggers the fetch. Runs whenever
* there is an active user regardless of `Account.isPremium` — users whose Stripe
* subscription has moved to a terminal state need their substate surfaced even though
* the server reports them as non-premium. Emits [SubscriptionStatusState.NoSubscription]
* when there is no active user or when the server returns 404 (no `GatewaySubscriptionId`,
* i.e. genuinely free users).
*/
@OptIn(ExperimentalCoroutinesApi::class)
override val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState> =
authDiskSource
.activeUserIdChangesFlow
.flatMapLatest { userId ->
if (userId != null) {
fetchSubscriptionStatusFlow()
} else {
flowOf(SubscriptionStatusState.NoSubscription)
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = SubscriptionStatusState.Loading,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState> =
combine(
authDiskSource.userStateFlow,
subscriptionStatusStateFlow,
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradePendingFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
) { userState, subscriptionStatus, isPending ->
deriveLifecycleState(
userState = userState,
subscriptionStatus = subscriptionStatus,
isPending = isPending,
)
}
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = deriveLifecycleState(
userState = authDiskSource.userState,
subscriptionStatus = subscriptionStatusStateFlow.value,
isPending = authDiskSource.userState
?.activeUserId
?.let { settingsDiskSource.getPremiumUpgradePending(userId = it) }
?: false,
),
)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
combine(
authDiskSource.userStateFlow,
authRepository.userStateFlow,
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow
@@ -146,71 +72,21 @@ class PremiumStateManagerImpl(
isDismissed,
vaultDataState,
->
BannerInputs(
userState = userState,
isInAppBillingSupported = isInAppBillingSupported,
featureFlagEnabled = featureFlagEnabled,
isDismissed = isDismissed,
vaultDataState = vaultDataState,
val activeAccount = userState?.activeAccount
?: return@combine false
val isPremium = activeAccount.isPremium
val isAccountOldEnough = activeAccount.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
}
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
val profile = inputs.userState?.activeAccount?.profile
?: return@combine false
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
(
lifecycle is UpgradeLifecycleState.Premium &&
lifecycle.subscriptionStatus.isInTroubleState()
)
val itemCount = vaultDataState.activeVaultItemCount()
lifecycleAllowsBanner &&
inputs.isInAppBillingSupported &&
inputs.featureFlagEnabled &&
!inputs.isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
override val isSelfHostedFlow: StateFlow<Boolean> =
combine(
environmentRepository.environmentStateFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.DebugDisableSelfHostPremiumCheck),
) { environment, isDebugBypassEnabled ->
environment is Environment.SelfHosted && !isDebugBypassEnabled
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = environmentRepository.environment is Environment.SelfHosted &&
!featureFlagManager.getFeatureFlag(FlagKey.DebugDisableSelfHostPremiumCheck),
)
/**
* Eligibility is keyed on the user holding personal Premium (or being eligible to purchase
* it). Organization-granted Premium does not surface the Plan row, since the user has no
* personal subscription to manage.
*/
override val isPlanRowEligibleFlow: StateFlow<Boolean> =
combine(
authDiskSource.userStateFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
) { userState, featureFlagEnabled ->
val profile = userState?.activeAccount?.profile ?: return@combine false
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
val isPremiumFromSelf = profile.hasPremiumPersonally == true
val isOrgOnlyPremium = hasPremium && !isPremiumFromSelf
featureFlagEnabled && !isOrgOnlyPremium
!isPremium &&
isInAppBillingSupported &&
featureFlagEnabled &&
!isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
}
.stateIn(
scope = unconfinedScope,
@@ -218,12 +94,6 @@ class PremiumStateManagerImpl(
initialValue = false,
)
/**
* The card surfaces only while the active user holds personal Premium. This guards against
* non-personal upgrade signals (e.g., the debug menu trigger or a stray
* `PREMIUM_STATUS_CHANGED` push for an organization grant) marking the card pending for users
* with no personal subscription to celebrate.
*/
@OptIn(ExperimentalCoroutinesApi::class)
override val isUpgradedToPremiumCardEligibleFlow: StateFlow<Boolean> =
authDiskSource
@@ -239,14 +109,7 @@ class PremiumStateManagerImpl(
settingsDiskSource
.getUpgradedToPremiumCardConsumedFlow(userId)
.map { it ?: false },
authDiskSource
.userStateFlow
.map {
it?.activeAccount?.profile?.hasPremiumPersonally == true
},
) { isPending, isConsumed, isPremiumFromSelf ->
isPending && !isConsumed && isPremiumFromSelf
}
) { isPending, isConsumed -> isPending && !isConsumed }
}
}
.distinctUntilChanged()
@@ -265,41 +128,33 @@ class PremiumStateManagerImpl(
if (data.isPremium) {
markUpgradedToPremiumCardPending(userId = data.userId)
}
// Push always re-fetches: a status push can mean either "newly premium" or
// "subscription moved into a trouble state" (e.g. past_due → unpaid).
subscriptionRefreshTriggerFlow.tryEmit(Unit)
}
.launchIn(unconfinedScope)
// Sync-delta detection: observe the active user's personal premium flag transitioning
// false → true (e.g., F-Droid users without push support). Keyed on
// `hasPremiumPersonally` so that organization-granted premium does not trigger the
// personal-upgrade card.
authDiskSource
// Sync-delta detection: observe the active user's premium flag transitioning false → true
// (e.g., F-Droid users without push support). NOTE: UserState.Account.isPremium is
// derived from `hasPremium = isPremium || isPremiumFromOrganization` so this path may
// also fire for organization-granted premium. The push path (above) is personal-only and
// takes precedence on flavors that support it.
authRepository
.userStateFlow
.map { state ->
state?.activeAccount?.profile?.let {
it.userId to (it.hasPremiumPersonally == true)
}
state?.activeAccount?.let { it.userId to it.isPremium }
}
.distinctUntilChanged()
.scanPairs()
.onEach { (previous, current) ->
if (current == null) return@onEach
val (currentUserId, currentIsPremiumFromSelf) = current
if (!currentIsPremiumFromSelf) return@onEach
// Same user transitioning from non-personal-premium to personal-premium counts as
// an upgrade.
val (currentUserId, currentIsPremium) = current
if (!currentIsPremium) return@onEach
// Same user transitioning from non-premium to premium counts as an upgrade.
if (previous?.first == currentUserId && !previous.second) {
markUpgradedToPremiumCardPending(userId = currentUserId)
clearPremiumUpgradePending(userId = currentUserId)
}
}
.launchIn(unconfinedScope)
}
override val isSelfHosted: Boolean get() = isSelfHostedFlow.value
override fun isInAppUpgradeAvailable(): Boolean =
billingRepository.isInAppBillingSupportedFlow.value &&
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
@@ -324,35 +179,6 @@ class PremiumStateManagerImpl(
)
}
override fun markPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = true,
)
}
private fun clearPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = null,
)
}
private fun deriveLifecycleState(
userState: UserStateJson?,
subscriptionStatus: SubscriptionStatusState,
isPending: Boolean,
): UpgradeLifecycleState {
val profile = userState?.activeAccount?.profile ?: return UpgradeLifecycleState.Free
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
return when {
hasPremium -> UpgradeLifecycleState.Premium(subscriptionStatus = subscriptionStatus)
isPending -> UpgradeLifecycleState.UpgradePending
else -> UpgradeLifecycleState.Free
}
}
private fun markUpgradedToPremiumCardPending(userId: String) {
// Don't re-arm the card if the user has already consumed it for this account.
if (settingsDiskSource.getUpgradedToPremiumCardConsumed(userId = userId) == true) {
@@ -363,58 +189,8 @@ class PremiumStateManagerImpl(
isPending = true,
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun fetchSubscriptionStatusFlow(): Flow<SubscriptionStatusState> =
merge(
flowOf(Unit),
subscriptionRefreshTriggerFlow,
)
.flatMapLatest {
flow {
emit(SubscriptionStatusState.Loading)
emit(fetchSubscriptionStatusOnce())
}
}
private suspend fun fetchSubscriptionStatusOnce(): SubscriptionStatusState =
when (val result = billingRepository.getSubscription()) {
is SubscriptionResult.Success -> {
SubscriptionStatusState.Available(status = result.subscription.status)
}
SubscriptionResult.NotFound -> SubscriptionStatusState.NoSubscription
is SubscriptionResult.Error -> SubscriptionStatusState.Error(throwable = result.error)
}
}
private data class BannerInputs(
val userState: UserStateJson?,
val isInAppBillingSupported: Boolean,
val featureFlagEnabled: Boolean,
val isDismissed: Boolean,
val vaultDataState: DataState<VaultData>,
)
/**
* Returns `true` when the given [SubscriptionStatusState] represents a subscription substate
* that should disqualify a user from being treated as effectively premium.
*/
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
this is SubscriptionStatusState.Available &&
when (this.status) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> false
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.

View File

@@ -32,9 +32,7 @@ interface BillingRepository {
suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult
/**
* Fetches the current user's premium subscription details. The endpoint 404s when the
* user has no `GatewaySubscriptionId` (free user); callers receive
* [SubscriptionResult.NotFound] in that case instead of [SubscriptionResult.Error].
* Fetches the current user's premium subscription details.
*/
suspend fun getSubscription(): SubscriptionResult
}

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.billing.repository
import com.bitwarden.network.model.GetSubscriptionResponse
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
@@ -55,14 +54,10 @@ class BillingRepositoryImpl(
billingService
.getSubscription()
.fold(
onSuccess = { response ->
when (response) {
is GetSubscriptionResponse.Success -> SubscriptionResult.Success(
subscription = response.subscription.toSubscriptionInfo(),
)
is GetSubscriptionResponse.NotFound -> SubscriptionResult.NotFound
}
onSuccess = {
SubscriptionResult.Success(
subscription = it.toSubscriptionInfo(),
)
},
onFailure = { SubscriptionResult.Error(error = it) },
)

View File

@@ -6,19 +6,7 @@ package com.x8bit.bitwarden.data.billing.repository.model
enum class PremiumSubscriptionStatus {
ACTIVE,
CANCELED,
/**
* The subscription's initial payment never succeeded and Stripe voided the invoice, so
* the subscription never became active. Distinct from [CANCELED], which describes a
* subscription that was previously active.
*/
EXPIRED,
/**
* The subscription is scheduled to cancel at a future date but is still active until then.
*/
PENDING_CANCELLATION,
OVERDUE_PAYMENT,
PAST_DUE,
PAUSED,
UPDATE_PAYMENT,
}

View File

@@ -17,8 +17,6 @@ import java.time.Instant
* @property nextChargeTotal The total of the next invoice:
* `seatsCost + (storageCost ?: 0) - (discountAmount ?: 0) + estimatedTax`.
* @property nextCharge The date of the next charge, or null if not applicable.
* @property cancelAt The date the subscription is scheduled to cancel at (the subscription is
* still active until this date), or null if no future cancellation is scheduled.
* @property canceledDate The date the subscription was canceled, or null.
* @property suspensionDate The date the subscription will be suspended, or null.
* @property gracePeriodDays The grace period in days, or null.
@@ -32,7 +30,6 @@ data class SubscriptionInfo(
val estimatedTax: BigDecimal,
val nextChargeTotal: BigDecimal,
val nextCharge: Instant?,
val cancelAt: Instant?,
val canceledDate: Instant?,
val suspensionDate: Instant?,
val gracePeriodDays: Int?,

View File

@@ -11,13 +11,6 @@ sealed class SubscriptionResult {
val subscription: SubscriptionInfo,
) : SubscriptionResult()
/**
* The endpoint returned 404, indicating the user has no subscription on record
* (e.g., the active account has never had a Stripe `GatewaySubscriptionId`).
* Consumers should treat this as a free user.
*/
data object NotFound : SubscriptionResult()
/**
* An error occurred while fetching subscription details.
*/

View File

@@ -1,36 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Latest observed substate of the active user's premium subscription.
*
* The subscription endpoint is only meaningful for users who have a `GatewaySubscriptionId`
* on the server, so [NoSubscription] is emitted both for users we never queried (no personal
* premium signal) and for users whose fetch returned 404. [Error] preserves the failure for
* retry, while [Available] surfaces the raw status so consumers can apply their own policy.
*/
sealed class SubscriptionStatusState {
/**
* No fetch has been attempted yet for the active user.
*/
data object Loading : SubscriptionStatusState()
/**
* The active user has no recorded premium subscription.
*/
data object NoSubscription : SubscriptionStatusState()
/**
* The active user has a subscription with the given [status].
*/
data class Available(
val status: PremiumSubscriptionStatus,
) : SubscriptionStatusState()
/**
* The fetch failed for a reason other than 404.
*/
data class Error(
val throwable: Throwable,
) : SubscriptionStatusState()
}

View File

@@ -1,33 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Represents the active user's position in the Premium upgrade lifecycle.
*
* Transitions:
* - [Free] → [UpgradePending] when the user completes Stripe checkout and the post-checkout
* sync still reports the user as non-premium — checkout is done, backend reconciliation
* is in flight.
* - [UpgradePending] → [Premium] when the server flips `isPremium` to `true`.
*
* Cancellation, expiration, and other terminal substates are surfaced via
* [Premium.subscriptionStatus] rather than as separate leaves.
*/
sealed class UpgradeLifecycleState {
/**
* The user has no Premium subscription and no upgrade is in flight.
*/
data object Free : UpgradeLifecycleState()
/**
* Stripe checkout completed but the server has not yet flipped `isPremium`.
*/
data object UpgradePending : UpgradeLifecycleState()
/**
* The user holds Premium; [subscriptionStatus] carries the substate (active, canceled, etc).
*/
data class Premium(
val subscriptionStatus: SubscriptionStatusState,
) : UpgradeLifecycleState()
}

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
@@ -12,40 +11,33 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import java.math.BigDecimal
import java.math.RoundingMode
private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100")
private const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
* model.
*
* Each line item's `cost` is a per-unit price, so its contribution is
* `cost * quantity`. Two discount channels are combined into `discountAmount`:
* the cart-level discount applies to the password manager subtotal
* (`seatsCost + storageCost`), and the Password Manager seats item-level
* discount applies to the seats line total. Item-level discounts on other line
* items are intentionally ignored, mirroring the web client. Fixed-amount
* discounts pass through as-is; percent-off discounts treat a value below 1 as
* an already-decimal fraction and round half-up. `nextChargeTotal` is computed
* client-side as `subtotal - discountAmount + estimatedTax` because the server
* `discountAmount` is resolved at mapping time: fixed-amount discounts pass
* through as-is; percent-off discounts apply to the password manager subtotal
* (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as
* `seatsCost + storageCost - discountAmount + estimatedTax` because the server
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.lineTotal()
val storageCost = cart.passwordManager.additionalStorage?.lineTotal()
val subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO)
val cartDiscount = cart.discount?.toDiscountAmount(baseAmount = subtotal)
val seatsDiscount = cart.passwordManager.seats.discount
?.toDiscountAmount(baseAmount = seatsCost)
val discountAmount = listOfNotNull(cartDiscount, seatsDiscount)
.takeIf { it.isNotEmpty() }
?.reduce(BigDecimal::add)
val seatsCost = cart.passwordManager.seats.cost
val storageCost = cart.passwordManager.additionalStorage?.cost
val discountAmount = cart.discount?.toMoneyAmount(
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = subtotal -
val nextChargeTotal = seatsCost +
(storageCost ?: BigDecimal.ZERO) -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
return SubscriptionInfo(
status = toPremiumSubscriptionStatus(),
status = status.toPremiumSubscriptionStatus(),
cadence = cart.cadence.toPlanCadence(),
seatsCost = seatsCost,
storageCost = storageCost,
@@ -53,49 +45,41 @@ fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
estimatedTax = estimatedTax,
nextChargeTotal = nextChargeTotal,
nextCharge = nextCharge,
cancelAt = cancelAt,
canceledDate = canceled,
suspensionDate = suspension,
gracePeriodDays = gracePeriod,
)
}
private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
PremiumSubscriptionStatus = when (status) {
SubscriptionStatusJson.ACTIVE,
SubscriptionStatusJson.TRIALING,
-> {
if (cancelAt != null) {
PremiumSubscriptionStatus.PENDING_CANCELLATION
} else {
PremiumSubscriptionStatus.ACTIVE
}
private fun SubscriptionStatusJson.toPremiumSubscriptionStatus(): PremiumSubscriptionStatus =
when (this) {
SubscriptionStatusJson.ACTIVE,
SubscriptionStatusJson.TRIALING,
-> PremiumSubscriptionStatus.ACTIVE
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
-> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.OVERDUE_PAYMENT
SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
SubscriptionStatusJson.CANCELED -> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> PremiumSubscriptionStatus.EXPIRED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.UPDATE_PAYMENT
SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CartItemJson.lineTotal(): BigDecimal = cost.multiply(quantity.toBigDecimal())
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toDiscountAmount(baseAmount: BigDecimal): BigDecimal =
private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF -> {
val percentage = if (value < BigDecimal.ONE) value else value.movePointLeft(2)
baseAmount.multiply(percentage).setScale(MONEY_SCALE, RoundingMode.HALF_UP)
}
DiscountTypeJson.PERCENT_OFF ->
subtotal
.multiply(value)
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
}

View File

@@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2Credenti
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.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -64,10 +65,12 @@ class BitwardenCredentialManagerImpl(
private val vaultRepository: VaultRepository,
private val cipherMatchingManager: CipherMatchingManager,
private val passkeyAttestationOptionsSanitizer: PasskeyAttestationOptionsSanitizer,
private val dispatcherManager: DispatcherManager,
dispatcherManager: DispatcherManager,
) : BitwardenCredentialManager,
Fido2CredentialStore by fido2CredentialStore {
private val ioScope = CoroutineScope(dispatcherManager.io)
override var isUserVerified: Boolean = false
override var authenticationAttempts: Int = 0
@@ -176,7 +179,7 @@ class BitwardenCredentialManagerImpl(
override suspend fun getCredentialEntries(
getCredentialsRequest: GetCredentialsRequest,
): Result<List<CredentialEntry>> = withContext(dispatcherManager.io) {
): Result<List<CredentialEntry>> = withContext(ioScope.coroutineContext) {
val cipherListViews = vaultRepository
.decryptCipherListResultStateFlow
.takeUntilLoaded()

View File

@@ -39,7 +39,7 @@ private const val RELEASE_BUILD = "release"
class PrivilegedAppRepositoryImpl(
private val privilegedAppDiskSource: PrivilegedAppDiskSource,
private val assetManager: AssetManager,
private val dispatcherManager: DispatcherManager,
dispatcherManager: DispatcherManager,
private val json: Json,
) : PrivilegedAppRepository {
@@ -118,7 +118,7 @@ class PrivilegedAppRepositoryImpl(
.toPrivilegedAppAllowListJson()
override suspend fun getGoogleTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? =
withContext(dispatcherManager.io) {
withContext(ioScope.coroutineContext) {
assetManager
.readAsset(fileName = GOOGLE_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromStringOrNull<PrivilegedAppAllowListJson>(it) }
@@ -126,7 +126,7 @@ class PrivilegedAppRepositoryImpl(
}
override suspend fun getCommunityTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? {
return withContext(dispatcherManager.io) {
return withContext(ioScope.coroutineContext) {
assetManager
.readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromStringOrNull<PrivilegedAppAllowListJson>(it) }

View File

@@ -193,25 +193,6 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether a Premium upgrade is awaiting server confirmation
* for the given [userId].
*/
fun getPremiumUpgradePending(userId: String): Boolean?
/**
* Stores whether a Premium upgrade is awaiting server confirmation for the given [userId].
*/
fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
)
/**
* Emits updates that track [getPremiumUpgradePending] for the given [userId].
*/
fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -58,13 +58,11 @@ private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED =
"upgradedToPremiumCardConsumed"
private const val UPGRADED_TO_PREMIUM_CARD_PENDING =
"upgradedToPremiumCardPending"
private const val PREMIUM_UPGRADE_PENDING =
"premiumUpgradePending"
/**
* Primary implementation of [SettingsDiskSource].
*/
@Suppress("TooManyFunctions", "LargeClass")
@Suppress("TooManyFunctions")
class SettingsDiskSourceImpl(
private val sharedPreferences: SharedPreferences,
private val json: Json,
@@ -110,9 +108,6 @@ class SettingsDiskSourceImpl(
private val mutableUpgradedToPremiumCardPendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradePendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -283,7 +278,6 @@ class SettingsDiskSourceImpl(
// - Premium upgrade banner dismissed
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
// - Premium upgrade pending
// - Has shown accessibility disclaimer dialog
}
@@ -367,26 +361,6 @@ class SettingsDiskSourceImpl(
getMutableUpgradedToPremiumCardPendingFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) }
override fun getPremiumUpgradePending(userId: String): Boolean? =
getBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
)
override fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
) {
putBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
value = isPending,
)
getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending)
}
override fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradePendingFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradePending(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -752,13 +726,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradePendingFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradePendingFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -15,7 +15,6 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -59,7 +58,6 @@ object PlatformNetworkModule {
certificateManager: CertificateManager,
buildInfoManager: BuildInfoManager,
networkCookieManager: NetworkCookieManager,
networkPermissionManager: NetworkPermissionManager,
clock: Clock,
): BitwardenServiceClientConfig = BitwardenServiceClientConfig(
clock = clock,
@@ -74,7 +72,6 @@ object PlatformNetworkModule {
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
permissionProvider = networkPermissionManager,
)
@Provides

View File

@@ -11,11 +11,6 @@ import timber.log.Timber
abstract class BaseSdkSource(
protected val sdkClientManager: SdkClientManager,
) {
/**
* Helper function to retrieve the global [Client] synchronously.
*/
protected val globalClient get() = sdkClientManager.globalClient
/**
* Helper function to retrieve the [Client] associated with the given [userId].
*/

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
/**
@@ -11,20 +11,20 @@ interface PolicyManager {
/**
* Returns a flow of all the active policies of the given type.
*/
fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>>
fun getActivePoliciesFlow(type: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>>
/**
* Get all the policies of the given [type] that are enabled and applicable to the user.
*/
fun getActivePolicies(type: PolicyType): List<PolicyView>
fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy>
/**
* Get all the policies of the given [type] that are enabled and applicable to the [userId].
*/
fun getUserPolicies(
userId: String,
type: PolicyType,
): List<PolicyView>
type: PolicyTypeJson,
): List<SyncResponseJson.Policy>
/**
* Get the organization id of the personal ownership policy.

View File

@@ -1,24 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.vault.repository.util.toSdkOrganizationPolicyContext
import com.x8bit.bitwarden.data.vault.repository.util.toSdkPolicyViews
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
/**
* The default [PolicyManager] implementation. This class is responsible for
@@ -26,151 +19,114 @@ import kotlinx.coroutines.flow.map
*/
class PolicyManagerImpl(
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val featureFlagManager: FeatureFlagManager,
) : PolicyManager {
@OptIn(ExperimentalCoroutinesApi::class)
override fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>> =
override fun getActivePoliciesFlow(type: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>> =
authDiskSource
.activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
activeUserId
?.let { userId -> getAppliedPolicyViewsFlow(userId = userId, type = type) }
?.let { userId ->
authDiskSource
.getPoliciesFlow(userId)
.mapNotNull {
filterPolicies(
userId = userId,
type = type,
policies = it,
)
}
}
?: emptyFlow()
}
.distinctUntilChanged()
override fun getActivePolicies(type: PolicyType): List<PolicyView> =
override fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy> =
authDiskSource
.userState
?.activeUserId
?.let { userId -> getUserPolicies(userId = userId, type = type) }
.orEmpty()
?.let { userId ->
filterPolicies(
userId = userId,
type = type,
policies = authDiskSource.getPolicies(userId = userId),
)
}
?: emptyList()
override fun getUserPolicies(
userId: String,
type: PolicyType,
): List<PolicyView> =
type: PolicyTypeJson,
): List<SyncResponseJson.Policy> =
this
.filterPolicies(
userId = userId,
type = type,
policies = authDiskSource
.getPolicies(userId = userId)
?.toSdkPolicyViews(),
organizations = authDiskSource
.getOrganizations(userId = userId)
?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
},
isPoliciesInAcceptedStateEnabled = featureFlagManager
.getFeatureFlag(key = FlagKey.PoliciesInAcceptedState),
policies = authDiskSource.getPolicies(userId = userId),
)
.orEmpty()
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
this
.getActivePolicies(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.sortedBy { it.revisionDate }
.firstOrNull()
?.organizationId
private fun getAppliedPolicyViewsFlow(
userId: String,
type: PolicyType,
): Flow<List<PolicyView>> = combine(
authDiskSource
.getPoliciesFlow(userId = userId)
.map { it?.toSdkPolicyViews() },
authDiskSource
.getOrganizationsFlow(userId = userId)
.map { organizations ->
organizations?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
}
},
featureFlagManager.getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState),
) { policies, organizations, isEnabled ->
filterPolicies(
type = type,
policies = policies,
organizations = organizations,
isPoliciesInAcceptedStateEnabled = isEnabled,
)
}
// We do not have any policies yet if it is null, so do not emit at all.
.filterNotNull()
/**
* A helper method to filter policies.
*/
private fun filterPolicies(
type: PolicyType,
policies: List<PolicyView>?,
organizations: List<OrganizationPolicyData>?,
isPoliciesInAcceptedStateEnabled: Boolean,
): List<PolicyView>? =
when {
policies == null -> null
policies.isEmpty() -> emptyList()
isPoliciesInAcceptedStateEnabled -> {
authSdkSource
.filterPolicies(
policies = policies,
policyType = type,
organizations = organizations
?.map { it.organizationUserPolicyContext }
.orEmpty(),
)
.getOrElse { emptyList() }
}
userId: String,
type: PolicyTypeJson,
policies: List<SyncResponseJson.Policy>?,
): List<SyncResponseJson.Policy>? {
policies ?: return null
if (policies.isEmpty()) return emptyList()
else -> {
// Legacy flow
val organizationIdsWithActivePolicies = organizations
?.filter {
@Suppress("MaxLineLength")
it.organizationUserPolicyContext.usePolicies &&
it.organizationUserPolicyContext.status >= OrganizationUserStatusType.ACCEPTED &&
!it.isOrganizationExemptFromPolicies(policyType = type)
}
?.map { it.organizationUserPolicyContext.id }
.orEmpty()
return policies.filter {
it.type == type &&
it.enabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
// Get a list of the user's organizations that enforce policies.
val organizationIdsWithActivePolicies = authDiskSource
.getOrganizations(userId)
?.filter {
it.shouldUsePolicies &&
it.status >= OrganizationStatusType.ACCEPTED &&
!isOrganizationExemptFromPolicies(it, type)
}
?.map { it.id }
.orEmpty()
// Filter the policies based on the type, whether the policy is active,
// and whether the organization rules except the user from the policy.
return policies.filter {
it.type == type &&
it.isEnabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
}
/**
* A helper method to determine if the organization is exempt from policies.
*/
private fun OrganizationPolicyData.isOrganizationExemptFromPolicies(
policyType: PolicyType,
private fun isOrganizationExemptFromPolicies(
organization: SyncResponseJson.Profile.Organization,
policyType: PolicyTypeJson,
): Boolean =
when (policyType) {
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
organization.type == OrganizationType.OWNER
}
PolicyType.PASSWORD_GENERATOR,
PolicyType.REMOVE_UNLOCK_WITH_PIN,
PolicyType.RESTRICTED_ITEM_TYPES,
-> false
PolicyTypeJson.PASSWORD_GENERATOR,
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN,
PolicyTypeJson.RESTRICT_ITEM_TYPES,
-> {
false
}
else -> {
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER ||
this.organizationUserPolicyContext.role == OrganizationUserType.ADMIN ||
this.organizationShouldUsePolicies
(organization.type == OrganizationType.OWNER ||
organization.type == OrganizationType.ADMIN) ||
organization.permissions.shouldManagePolicies
}
}
}
private data class OrganizationPolicyData(
val organizationUserPolicyContext: OrganizationUserPolicyContext,
val organizationShouldUsePolicies: Boolean,
)

View File

@@ -7,13 +7,6 @@ import com.bitwarden.sdk.Client
*/
interface SdkClientManager {
/**
* Synchronously returns a [Client] that is unassociated with any user. It cannot be used for
* anything that performs a network requests. If the client is not yet ready, this will block
* until it is ready.
*/
val globalClient: Client
/**
* Returns the cached [Client] instance for the given [userId], otherwise creates and caches
* a new one and returns it.

View File

@@ -1,24 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.concurrentMapOf
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.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
nativeLibraryManager: NativeLibraryManager,
dispatcherManager: DispatcherManager,
sdkRepoFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
private val featureFlagManager: FeatureFlagManager,
@@ -45,9 +38,7 @@ class SdkClientManagerImpl(
}
},
) : SdkClientManager {
private val userIdToClientMap = concurrentMapOf<String, Client>()
private val ioScope = CoroutineScope(context = dispatcherManager.io)
private val globalClientDeferred: Deferred<Client>
private val userIdToClientMap = mutableMapOf<String, Client>()
init {
// The SDK requires access to Android APIs that were not made public until API 31. In order
@@ -56,13 +47,8 @@ class SdkClientManagerImpl(
if (!isBuildVersionAtLeast(Build.VERSION_CODES.S)) {
nativeLibraryManager.loadLibrary("bitwarden_uniffi")
}
// Initialize this now, so that we can access it synchronously later on.
globalClientDeferred = ioScope.async { clientProvider(null, null) }
}
override val globalClient: Client
get() = runBlocking { globalClientDeferred.await() }
override suspend fun getOrCreateClient(
userId: String,
): Client = userIdToClientMap.getOrPut(key = userId) { clientProvider(userId, null) }

View File

@@ -19,7 +19,6 @@ import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.PushService
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.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
@@ -75,8 +74,6 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManage
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
@@ -221,13 +218,11 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideSdkClientManager(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
nativeLibraryManager: NativeLibraryManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
): SdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
nativeLibraryManager = nativeLibraryManager,
sdkRepoFactory = sdkRepositoryFactory,
@@ -265,12 +260,8 @@ object PlatformManagerModule {
@Singleton
fun providePolicyManager(
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
featureFlagManager: FeatureFlagManager,
): PolicyManager = PolicyManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
featureFlagManager = featureFlagManager,
)
@Provides
@@ -459,14 +450,4 @@ object PlatformManagerModule {
cookieDiskSource = cookieDiskSource,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
)
@Provides
@Singleton
fun provideNetworkPermissionManager(
@ApplicationContext context: Context,
resourceManager: ResourceManager,
): NetworkPermissionManager = NetworkPermissionManagerImpl(
context = context,
resourceManager = resourceManager,
)
}

View File

@@ -5,9 +5,9 @@ import androidx.credentials.CredentialManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.ui.platform.manager.share.model.ShareData
import com.bitwarden.ui.platform.model.TotpData
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
@@ -144,13 +144,6 @@ sealed class SpecialCircumstance : Parcelable {
val callbackResult: PremiumCheckoutCallbackResult,
) : SpecialCircumstance()
/**
* The user has returned from the Stripe customer portal (launched to manage or cancel their
* subscription). The close of the portal is the only signal — there is no callback payload.
*/
@Parcelize
data object StripePortal : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.
*/

View File

@@ -1,21 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.network
import com.bitwarden.network.provider.PermissionProvider
import kotlinx.coroutines.flow.StateFlow
/**
* A manager class for handling network permissions.
*/
interface NetworkPermissionManager : PermissionProvider {
/**
* StateFlow indicating if local network access is being requested at this moment.
*
* Emits `true` when local network access is required, `false` otherwise.
*/
val isLocalNetworkAccessRequiredStateFlow: StateFlow<Boolean>
/**
* Sets the local network access required state to `false`.
*/
fun clearIsLocalNetworkAccessRequired()
}

View File

@@ -1,49 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.network
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The default implementation of [NetworkPermissionManager].
*/
internal class NetworkPermissionManagerImpl(
private val context: Context,
private val resourceManager: ResourceManager,
) : NetworkPermissionManager {
private val mutableIsLocalNetworkAccessRequiredStateFlow = MutableStateFlow(value = false)
override val errorMessageString: String
get() = resourceManager.getString(
resId = BitwardenString
.your_request_was_interrupted_because_the_app_needs_local_network_access,
)
override val hasLocalNetworkAccessPermission: Boolean
get() = if (isBuildVersionAtLeast(version = Build.VERSION_CODES.CINNAMON_BUN)) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
override val isLocalNetworkAccessRequiredStateFlow: StateFlow<Boolean> =
mutableIsLocalNetworkAccessRequiredStateFlow
override fun acquireLocalNetworkAccessPermission() {
mutableIsLocalNetworkAccessRequiredStateFlow.value = true
}
override fun clearIsLocalNetworkAccessRequired() {
mutableIsLocalNetworkAccessRequiredStateFlow.value = false
}
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.bitwarden.policies.PolicyType
import com.bitwarden.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.map
*/
inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): List<T> =
this
.getActivePolicies(type = getPolicyType<T>())
.getActivePolicies(type = getPolicyTypeJson<T>())
.mapNotNull { it.policyInformation as? T }
/**
@@ -20,21 +20,21 @@ inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): Li
*/
inline fun <reified T : PolicyInformation> PolicyManager.getActivePoliciesFlow(): Flow<List<T>> =
this
.getActivePoliciesFlow(type = getPolicyType<T>())
.getActivePoliciesFlow(type = getPolicyTypeJson<T>())
.map { policies ->
policies.mapNotNull { policy -> policy.policyInformation as? T }
}
/**
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyType]
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyTypeJson]
* counterpart.
*/
inline fun <reified T : PolicyInformation> getPolicyType(): PolicyType =
inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson =
when (T::class.java) {
PolicyInformation.MasterPassword::class.java -> PolicyType.MASTER_PASSWORD
PolicyInformation.PasswordGenerator::class.java -> PolicyType.PASSWORD_GENERATOR
PolicyInformation.SendOptions::class.java -> PolicyType.SEND_OPTIONS
PolicyInformation.VaultTimeout::class.java -> PolicyType.MAXIMUM_VAULT_TIMEOUT
PolicyInformation.MasterPassword::class.java -> PolicyTypeJson.MASTER_PASSWORD
PolicyInformation.PasswordGenerator::class.java -> PolicyTypeJson.PASSWORD_GENERATOR
PolicyInformation.SendOptions::class.java -> PolicyTypeJson.SEND_OPTIONS
PolicyInformation.VaultTimeout::class.java -> PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT
else -> {
throw IllegalStateException(
@@ -43,10 +43,9 @@ inline fun <reified T : PolicyInformation> getPolicyType(): PolicyType =
)
}
}
/**
* Helper method for verifying if user has enabled the restrict item policy.
*/
fun PolicyManager.hasRestrictItemTypes(): Boolean =
getActivePolicies(type = PolicyType.RESTRICTED_ITEM_TYPES)
.any { it.enabled }
getActivePolicies(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
.any { it.isEnabled }

View File

@@ -10,7 +10,9 @@ import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
@@ -102,6 +104,11 @@ class AuthenticatorBridgeRepositoryImpl(
decryptedCipher.login?.totp?.let { rawTotp ->
SharedAccountData.CipherData(
uri = rawTotp,
// TODO: PM-34085 Remove the legacyUri.
legacyUri = rawTotp.sanitizeTotpUri(
issuer = cipherName,
username = username,
),
id = cipherId,
name = cipherName,
username = username,
@@ -134,17 +141,20 @@ class AuthenticatorBridgeRepositoryImpl(
account: AccountJson,
decryptedUserKey: String,
): VaultUnlockResult {
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Account Cryptographic State"),
MissingPropertyException("Private key"),
)
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
accountCryptographicState = accountCryptographicState,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
userId = userId,
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,

View File

@@ -4,8 +4,8 @@ import android.view.autofill.AutofillManager
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -384,7 +384,7 @@ class SettingsRepositoryImpl(
init {
policyManager
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
.onEach { updateVaultUnlockSettingsIfNecessary(it) }
.launchIn(unconfinedScope)
}
@@ -690,7 +690,7 @@ class SettingsRepositoryImpl(
* settings to determine whether to update the user's settings.
*/
private fun updateVaultUnlockSettingsIfNecessary(
policies: List<PolicyView>,
policies: List<SyncResponseJson.Policy>,
) {
// The vault timeout policy can only be implemented in organizations that have
// the single organization policy, meaning that if this is enabled, the user is

View File

@@ -0,0 +1,75 @@
package com.x8bit.bitwarden.data.platform.repository.util
import java.net.URLEncoder
private const val OTPAUTH_PREFIX = "otpauth://totp/"
private const val STEAM_PREFIX = "steam://"
/**
* Utility for ensuring that a given TOTP string is a properly formatted otpauth:// or steam:// URI.
* If the input TOTP is already a valid URI, it is returned as-is.
* If the TOTP is manually entered and does not follow the URI format,
* this function reconstructs it using the provided issuer and username.
*
* Uses this as a guide for format
* https://github.com/google/google-authenticator/wiki/Key-Uri-Format
*
* Replace spaces (+) with %20, and encode the label and issuer (per the above link)
* https://datatracker.ietf.org/doc/html/rfc5234
* */
fun String?.sanitizeTotpUri(
issuer: String?,
username: String?,
): String? {
if (this.isNullOrBlank()) return null
return if (this.startsWith(OTPAUTH_PREFIX) || this.startsWith(STEAM_PREFIX)) {
// ✅ Already a valid TOTP or Steam URI, return as-is.
this
} else {
// ❌ Manually entered secret, reconstruct as otpauth://totp/ URI.
// Trim spaces from issuer and username
val trimmedIssuer = issuer
?.trim()
?.takeIf { it.isNotEmpty() }
val trimmedUsername = username
?.trim()
?.takeIf { it.isNotEmpty() }
// Determine raw label correctly (avoid empty `:` issue)
val rawLabel = if (trimmedIssuer != null && trimmedUsername != null) {
"$trimmedIssuer:$trimmedUsername"
} else {
trimmedUsername
}
// Encode label only if it's not empty
val encodedLabel = rawLabel
?.let {
URLEncoder
.encode(it, "UTF-8")
.replace("+", "%20")
}
.orEmpty()
// Encode issuer separately for the query parameter
val encodedIssuer = trimmedIssuer?.let {
URLEncoder
.encode(it, "UTF-8")
.replace("+", "%20")
}
// Construct the issuer query parameter.
val issuerParameter = encodedIssuer
?.let { "&issuer=$it" }
.orEmpty()
// Remove spaces from the manually entered secret
val sanitizedSecret = this.filterNot { it.isWhitespace() }
// Construct final TOTP URI
"$OTPAUTH_PREFIX$encodedLabel?secret=$sanitizedSecret$issuerParameter"
}
}

View File

@@ -23,23 +23,23 @@ private fun EnvironmentUrlDataJson.authTabData(
kind: String,
): AuthTabData = when (this.environmentRegion) {
EnvironmentRegion.UNITED_STATES -> {
AuthTabData.HttpsScheme(
host = "bitwarden.com",
path = "$kind-callback",
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
EnvironmentRegion.EUROPEAN_UNION -> {
AuthTabData.HttpsScheme(
host = "bitwarden.eu",
path = "$kind-callback",
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
EnvironmentRegion.INTERNAL -> {
AuthTabData.HttpsScheme(
host = "bitwarden.pw",
path = "$kind-callback",
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
/**
* Returns a user-friendly error message if this [Throwable] is an allow-listed
@@ -9,7 +8,6 @@ import com.bitwarden.network.exception.LocalNetworkAccessException
*/
val Throwable.userFriendlyMessage: String?
get() = when (this) {
is LocalNetworkAccessException -> message
is CookieRedirectException -> message
else -> null
}

View File

@@ -1,13 +1,11 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.sdk
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.generators.PassphraseGeneratorRequest
import com.bitwarden.generators.PasswordGeneratorRequest
import com.bitwarden.generators.UsernameGeneratorRequest
import com.bitwarden.sdk.GeneratorClients
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import kotlinx.coroutines.withContext
/**
* Implementation of [GeneratorSdkSource] that delegates password generation.
@@ -16,7 +14,6 @@ import kotlinx.coroutines.withContext
* [GeneratorClients] provided by the Bitwarden SDK.
*/
class GeneratorSdkSourceImpl(
private val dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
GeneratorSdkSource {
@@ -54,8 +51,6 @@ class GeneratorSdkSourceImpl(
override suspend fun generateForwardedServiceEmail(
request: UsernameGeneratorRequest.Forwarded,
): Result<String> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
useClient { generators().username(request) }
}
useClient { generators().username(request) }
}
}

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.sdk.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSourceImpl
@@ -20,10 +19,6 @@ object GeneratorSdkModule {
@Provides
@Singleton
fun provideGeneratorSdkSource(
dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
): GeneratorSdkSource = GeneratorSdkSourceImpl(
dispatcherManager = dispatcherManager,
sdkClientManager = sdkClientManager,
)
): GeneratorSdkSource = GeneratorSdkSourceImpl(sdkClientManager = sdkClientManager)
}

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Clock
import javax.inject.Singleton
@@ -192,9 +193,8 @@ class GeneratorRepositoryImpl(
override suspend fun generateForwardedServiceUsername(
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
): GeneratedForwardedServiceUsernameResult =
generatorSdkSource
.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
): GeneratedForwardedServiceUsernameResult = withContext(scope.coroutineContext) {
generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
.fold(
onSuccess = { generatedEmail ->
GeneratedForwardedServiceUsernameResult.Success(generatedEmail)
@@ -203,6 +203,7 @@ class GeneratorRepositoryImpl(
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message, error = it)
},
)
}
override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? {
val userId = authDiskSource.userState?.activeUserId

View File

@@ -4,7 +4,6 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringWithErrorCallback
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -135,7 +134,7 @@ class VaultDiskSourceImpl(
.filter {
// A safety-check since after the DB migration, we will temporarily think
// all ciphers contain a totp code
it.login?.totp.orNullIfBlank() != null
it.login?.totp != null
}
}
}
@@ -167,7 +166,7 @@ class VaultDiskSourceImpl(
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp.orNullIfBlank() != null,
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,

View File

@@ -21,7 +21,6 @@ class ScopedVaultSdkSourceImpl(
sdkPlatformApiFactory: SdkPlatformApiFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
// We do not want to have the real NativeLibraryManager used here to avoid
// initializing the library twice.
nativeLibraryManager = object : NativeLibraryManager {

View File

@@ -190,9 +190,9 @@ class VaultSdkSourceImpl(
): Result<InitializeCryptoResult> =
runCatchingWithLogs {
try {
withContext(context = dispatcherManager.io) {
getClient(userId = userId).crypto().initializeUserCrypto(req = request)
}
getClient(userId = userId)
.crypto()
.initializeUserCrypto(req = request)
InitializeCryptoResult.Success
} catch (exception: BitwardenException) {
// The only truly expected error from the SDK is an incorrect key/password.

View File

@@ -130,6 +130,7 @@ interface CipherManager {
*/
suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult

View File

@@ -442,6 +442,7 @@ class CipherManagerImpl(
override suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error(error = NoActiveUserException())
@@ -450,18 +451,17 @@ class CipherManagerImpl(
cipherId = cipherId,
body = UpdateCipherCollectionsJsonRequest(collectionIds = collectionIds),
)
.onSuccess { response ->
response
.cipher
?.let {
// Save the updated cipher to disk.
vaultDiskSource.saveCipher(userId = userId, cipher = it)
}
?: run {
// The user no longer has any collection access to the cipher after
// the update, so remove it from disk.
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView.copy(collectionIds = collectionIds),
)
}
.onSuccess { encryptionContext ->
vaultDiskSource.saveCipher(
userId = userId,
cipher = encryptionContext.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { ShareCipherResult.Success },

View File

@@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
@@ -674,14 +675,17 @@ class VaultLockManagerImpl(
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Account Cryptographic State"),
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
accountCryptographicState = accountCryptographicState,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),

View File

@@ -8,11 +8,10 @@ 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.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import com.bitwarden.network.service.CiphersService
import com.bitwarden.policies.PolicyType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -145,7 +144,6 @@ class VaultMigrationManagerImpl(
val orgName = authDiskSource
.getOrganizations(userId = userId)
?.filter { it.status == OrganizationStatusType.CONFIRMED }
?.firstOrNull { it.id == orgId }
?.name
?: return@update VaultMigrationData.NoMigrationRequired
@@ -169,7 +167,9 @@ class VaultMigrationManagerImpl(
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
): Boolean =
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any() &&
policyManager
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.any() &&
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers

View File

@@ -7,7 +7,6 @@ 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.network.model.OrganizationStatusType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.service.SyncService
import com.bitwarden.network.util.isNoConnectionError
@@ -17,7 +16,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
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
@@ -378,14 +376,9 @@ class VaultSyncManagerImpl(
val profile = syncResponse.profile
val userId = profile.id
authDiskSource.apply {
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = profile.privateKeyOrNull()?.let {
profile.accountKeys.toAccountCryptographicState(
privateKey = it,
)
},
)
storeUserKey(userId = userId, userKey = profile.key)
storePrivateKey(userId = userId, privateKey = profile.privateKey)
storeAccountKeys(userId = userId, accountKeys = profile.accountKeys)
storeOrganizationKeys(
userId = userId,
organizationKeys = profile.organizations
@@ -476,9 +469,6 @@ class VaultSyncManagerImpl(
data = collections.sortAlphabeticallyByTypeAndOrganization(
userOrganizations = authDiskSource
.getOrganizations(userId = userId)
?.filter { org ->
org.status == OrganizationStatusType.CONFIRMED
}
.orEmpty(),
),
)
@@ -546,13 +536,6 @@ class VaultSyncManagerImpl(
?: DataState.Loading
}
/**
* Convenience function to extract the private key from the [SyncResponseJson.Profile] response.
*/
private fun SyncResponseJson.Profile.privateKeyOrNull(): String? =
this.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: this.privateKey
private fun <T> Throwable.toNetworkOrErrorState(
data: T?,
): DataState<T> =

View File

@@ -19,6 +19,7 @@ import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
@@ -41,7 +42,6 @@ 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.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
@@ -322,19 +322,21 @@ class VaultRepositoryImpl(
decryptedUserKey = decryptedUserKey,
),
)
.onVaultUnlockSuccess {
encryptedBiometricsKey?.let { key ->
// If this key is present, we store it and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(
.also {
if (it is VaultUnlockResult.Success) {
encryptedBiometricsKey?.let { key ->
// If this key is present, we store it and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = key,
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
biometricsKey = key,
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
}
}
@@ -361,10 +363,12 @@ class VaultRepositoryImpl(
userId = userId,
initUserCryptoMethod = initUserCryptoMethod,
)
.onVaultUnlockSuccess {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
.also {
if (it is VaultUnlockResult.Success) {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
}
}
}
@@ -555,14 +559,19 @@ class VaultRepositoryImpl(
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys
?.publicKeyEncryptionKeyPair
?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Account Cryptographic State"),
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return vaultLockManager.unlockVault(
accountCryptographicState = accountCryptographicState,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),

View File

@@ -46,18 +46,3 @@ sealed class VaultUnlockResult {
sealed interface VaultUnlockError {
val error: Throwable?
}
/**
* Invokes the [onSuccess] lambda as a side effect.
*/
inline fun VaultUnlockResult.onVaultUnlockSuccess(
onSuccess: () -> Unit,
): VaultUnlockResult = when (this) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> this
is VaultUnlockResult.Success -> this.also { onSuccess() }
}

View File

@@ -70,8 +70,8 @@ fun Cipher.toEncryptedNetworkCipher(
key = key,
sshKey = sshKey?.toEncryptedNetworkSshKey(),
bankAccount = bankAccount?.toEncryptedNetworkBankAccount(),
driversLicense = driversLicense?.toEncryptedNetworkDriversLicense(),
passport = passport?.toEncryptedNetworkPassport(),
driversLicense = null,
passport = null,
archivedDate = archivedDate,
encryptedFor = encryptedFor,
)

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