mirror of
https://github.com/bitwarden/android.git
synced 2026-06-10 16:46:10 -05:00
Compare commits
70 Commits
release/20
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc73fdd5c | ||
|
|
43f70f5b3b | ||
|
|
15689fcace | ||
|
|
3e928489c7 | ||
|
|
d3590935b0 | ||
|
|
ee4b9823d1 | ||
|
|
a3bcff9463 | ||
|
|
aca9949874 | ||
|
|
217bfc1097 | ||
|
|
a94978c8e2 | ||
|
|
0a920d5800 | ||
|
|
09f0f5b9bf | ||
|
|
b57fb9c437 | ||
|
|
124ce37bc3 | ||
|
|
e7e2c26bef | ||
|
|
c89a52e5d2 | ||
|
|
fb955e903f | ||
|
|
230c8f769d | ||
|
|
8661dfaf2f | ||
|
|
a872db128b | ||
|
|
40604d0ec0 | ||
|
|
0f4b3fb9f0 | ||
|
|
a0edef99e6 | ||
|
|
cc6fcecc5b | ||
|
|
58408bcd77 | ||
|
|
3732672ab4 | ||
|
|
b53d3fbd29 | ||
|
|
f0f1f91c62 | ||
|
|
ecc47005fb | ||
|
|
3fc5965a05 | ||
|
|
8cd52a716f | ||
|
|
7b94daf3ae | ||
|
|
a5f7288208 | ||
|
|
c6a439a791 | ||
|
|
34ce0edc03 | ||
|
|
f4507384e9 | ||
|
|
0005ed7a2f | ||
|
|
bf14dfbf40 | ||
|
|
fc2786d809 | ||
|
|
c6bef627a2 | ||
|
|
afe296c3db | ||
|
|
e4fb873d34 | ||
|
|
50960456c5 | ||
|
|
ebc3bd8081 | ||
|
|
8f72c10f8e | ||
|
|
c6746fb369 | ||
|
|
31011b5789 | ||
|
|
a9048c6393 | ||
|
|
9e27b950e8 | ||
|
|
8940a2c490 | ||
|
|
2eab66ecd3 | ||
|
|
fce814d6bd | ||
|
|
8002794c59 | ||
|
|
304c32e1a4 | ||
|
|
cc210a5764 | ||
|
|
5f3f9d186c | ||
|
|
6cf7227973 | ||
|
|
e949dd710a | ||
|
|
6ccb9d9f3e | ||
|
|
83a9d35e32 | ||
|
|
5f415e2deb | ||
|
|
1f8280f76d | ||
|
|
bc93d7c311 | ||
|
|
fcc1eebab1 | ||
|
|
e2ab1f5663 | ||
|
|
11bdb07bde | ||
|
|
4108811349 | ||
|
|
38a5e6fe55 | ||
|
|
2f6a36ce1a | ||
|
|
484d326e14 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.3
|
||||
version: 0.1.4
|
||||
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,6 +236,44 @@ 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
|
||||
@@ -504,6 +542,9 @@ 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
|
||||
|
||||
@@ -12,12 +12,12 @@ runs:
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ inputs.java-version }}
|
||||
|
||||
26
.github/label-pr.json
vendored
26
.github/label-pr.json
vendored
@@ -27,32 +27,6 @@
|
||||
],
|
||||
"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/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
4
.github/workflows/_version.yml
vendored
4
.github/workflows/_version.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
if: ${{ !inputs.skip_checkout || false }}
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
10
.github/workflows/build-authenticator.yml
vendored
10
.github/workflows/build-authenticator.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
|
||||
6
.github/workflows/build-testharness.yml
vendored
6
.github/workflows/build-testharness.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
artifact: ["apk", "aab"]
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
|
||||
6
.github/workflows/crowdin-pull.yml
vendored
6
.github/workflows/crowdin-pull.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
permission-pull-requests: write # for creating pull request
|
||||
|
||||
- name: Download translations
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
|
||||
|
||||
4
.github/workflows/crowdin-push.yml
vendored
4
.github/workflows/crowdin-push.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
secrets: "CROWDIN-API-TOKEN"
|
||||
|
||||
- name: Upload sources
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
|
||||
|
||||
2
.github/workflows/github-release.yml
vendored
2
.github/workflows/github-release.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
4
.github/workflows/publish-store.yml
vendored
4
.github/workflows/publish-store.yml
vendored
@@ -73,11 +73,11 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/release-branch.yml
vendored
2
.github/workflows/release-branch.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
2
.github/workflows/review-code.yml
vendored
2
.github/workflows/review-code.yml
vendored
@@ -2,7 +2,7 @@ name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [labeled, opened, ready_for_review, reopened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/sdlc-label-pr.yml
vendored
2
.github/workflows/sdlc-label-pr.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
6
.github/workflows/sdlc-sdk-update.yml
vendored
6
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
disable_search: true
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports-${{ matrix.group }}
|
||||
|
||||
29
Gemfile.lock
29
Gemfile.lock
@@ -8,8 +8,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1246.0)
|
||||
aws-sdk-core (3.246.0)
|
||||
aws-partitions (1.1249.0)
|
||||
aws-sdk-core (3.247.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.124.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (1.125.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.221.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-s3 (1.222.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -123,9 +123,10 @@ 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 (0.10.1)
|
||||
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
|
||||
google-apis-firebaseappdistribution_v1alpha (~> 0.2.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-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.100.0)
|
||||
@@ -138,10 +139,10 @@ GEM
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.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-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-iamcredentials_v1 (0.27.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
@@ -154,7 +155,7 @@ GEM
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
google-cloud-storage (1.60.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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" />
|
||||
|
||||
@@ -36,11 +36,15 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
|
||||
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
|
||||
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
|
||||
@@ -93,6 +97,22 @@ 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()
|
||||
var shouldShowSplashScreen = true
|
||||
@@ -115,13 +135,7 @@ class MainActivity : AppCompatActivity() {
|
||||
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
|
||||
LocalManagerProvider(
|
||||
featureFlagsState = state.featureFlagsState,
|
||||
authTabLaunchers = AuthTabLaunchers(
|
||||
duo = duoLauncher,
|
||||
sso = ssoLauncher,
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = cookieLauncher,
|
||||
premiumCheckout = premiumCheckoutLauncher,
|
||||
),
|
||||
authTabLaunchers = authTabLaunchers,
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
onDataUpdate = remember(mainViewModel) {
|
||||
@@ -151,6 +165,14 @@ class MainActivity : AppCompatActivity() {
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
localNetworkAccessDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
accessibilityDisclosureDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +257,13 @@ class MainActivity : AppCompatActivity() {
|
||||
MainEvent.Recreate -> handleRecreate()
|
||||
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
|
||||
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
|
||||
MainEvent.NavigateToLocalNetworkAccess -> {
|
||||
navController.navigateToLocalNetworkAccess()
|
||||
}
|
||||
|
||||
MainEvent.NavigateToAccessibilityDisclosure -> {
|
||||
navController.navigateToAccessibilityDisclosure()
|
||||
}
|
||||
|
||||
is MainEvent.UpdateAppLocale -> {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
|
||||
@@ -37,11 +37,13 @@ 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
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
|
||||
@@ -80,6 +82,7 @@ class MainViewModel @Inject constructor(
|
||||
accessibilitySelectionManager: AccessibilitySelectionManager,
|
||||
autofillSelectionManager: AutofillSelectionManager,
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
networkPermissionManager: NetworkPermissionManager,
|
||||
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
@@ -149,6 +152,12 @@ class MainViewModel @Inject constructor(
|
||||
.onEach(::trySendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.hasShownAccessibilityDisclaimerFlow
|
||||
.map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) }
|
||||
.onEach(::trySendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
merge(
|
||||
authRepository
|
||||
.userStateFlow
|
||||
@@ -168,6 +177,13 @@ class MainViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
networkPermissionManager
|
||||
.isLocalNetworkAccessRequiredStateFlow
|
||||
.filter { it }
|
||||
.map { MainAction.Internal.LocalNetworkAccessRequired }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
cookieAcquisitionRequestManager
|
||||
.cookieAcquisitionRequestFlow
|
||||
.filterNotNull()
|
||||
@@ -201,6 +217,7 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -223,7 +240,19 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHasShownAccessibilityDisclaimerUpdate(
|
||||
action: MainAction.Internal.HasShownAccessibilityDisclaimerUpdate,
|
||||
) {
|
||||
if (!action.hasBeenShown) {
|
||||
sendEvent(MainEvent.NavigateToAccessibilityDisclosure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +286,10 @@ 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()
|
||||
@@ -304,6 +337,10 @@ class MainViewModel @Inject constructor(
|
||||
sendEvent(MainEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleLocalNetworkAccessRequired() {
|
||||
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
|
||||
}
|
||||
|
||||
private fun handleResizeHasBeenRequested() {
|
||||
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
|
||||
}
|
||||
@@ -579,6 +616,14 @@ 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.
|
||||
*/
|
||||
@@ -656,10 +701,20 @@ 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
|
||||
*/
|
||||
data object ResizeHasBeenRequested : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the accessibility disclaimer has been displayed.
|
||||
*/
|
||||
data class HasShownAccessibilityDisclaimerUpdate(val hasBeenShown: Boolean) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +749,16 @@ sealed class MainEvent {
|
||||
*/
|
||||
data object NavigateToCookieAcquisition : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the local network access screen.
|
||||
*/
|
||||
data object NavigateToLocalNetworkAccess : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the accessibility disclosure screen.
|
||||
*/
|
||||
data object NavigateToAccessibilityDisclosure : MainEvent()
|
||||
|
||||
/**
|
||||
* Indicates that the app language has been updated.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
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
|
||||
@@ -14,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStor
|
||||
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
|
||||
@@ -485,8 +487,13 @@ class AuthDiskSourceImpl(
|
||||
getString(key = POLICIES_KEY.appendIdentifier(userId))
|
||||
?.let {
|
||||
// The policies are stored as a map.
|
||||
val policiesMap: Map<String, SyncResponseJson.Policy>? =
|
||||
json.decodeFromStringOrNull(it)
|
||||
val policiesMap = json.decodeFromStringOrNull(
|
||||
deserializer = SafeMapSerializer(
|
||||
keySerializer = String.serializer(),
|
||||
valueSerializer = SyncResponseJson.Policy.serializer(),
|
||||
),
|
||||
string = it,
|
||||
)
|
||||
policiesMap?.values?.toList()
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,11 @@ 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 hasPremium True if the user has a Premium account.
|
||||
* @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 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.
|
||||
@@ -80,7 +84,10 @@ data class AccountJson(
|
||||
val avatarColorHex: String?,
|
||||
|
||||
@SerialName("hasPremiumPersonally")
|
||||
val hasPremium: Boolean?,
|
||||
val hasPremiumPersonally: Boolean?,
|
||||
|
||||
@SerialName("hasPremiumFromOrganization")
|
||||
val hasPremiumFromOrganization: Boolean?,
|
||||
|
||||
@SerialName("forcePasswordResetReason")
|
||||
val forcePasswordResetReason: ForcePasswordResetReason?,
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
|
||||
/**
|
||||
@@ -134,4 +137,13 @@ 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>>
|
||||
}
|
||||
|
||||
@@ -13,14 +13,19 @@ 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
|
||||
@@ -28,6 +33,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class AuthSdkSourceImpl(
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
sdkClientManager: SdkClientManager,
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
AuthSdkSource {
|
||||
@@ -42,10 +48,8 @@ class AuthSdkSourceImpl(
|
||||
masterPasswordHint: String?,
|
||||
shouldResetPasswordEnroll: Boolean,
|
||||
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.registration()
|
||||
.postKeysForJitPasswordRegistration(
|
||||
withContext(context = dispatcherManager.io) {
|
||||
getClient(userId = userId).auth().registration().postKeysForJitPasswordRegistration(
|
||||
request = JitMasterPasswordRegistrationRequest(
|
||||
orgId = organizationId,
|
||||
orgPublicKey = organizationPublicKey,
|
||||
@@ -57,6 +61,7 @@ class AuthSdkSourceImpl(
|
||||
resetPasswordEnroll = shouldResetPasswordEnroll,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun postKeysForKeyConnectorRegistration(
|
||||
@@ -65,11 +70,13 @@ class AuthSdkSourceImpl(
|
||||
keyConnectorUrl: String,
|
||||
ssoOrganizationIdentifier: String,
|
||||
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
|
||||
useClient(userId = userId, accessToken = accessToken) {
|
||||
auth().registration().postKeysForKeyConnectorRegistration(
|
||||
keyConnectorUrl = keyConnectorUrl,
|
||||
ssoOrgIdentifier = ssoOrganizationIdentifier,
|
||||
)
|
||||
withContext(context = dispatcherManager.io) {
|
||||
useClient(userId = userId, accessToken = accessToken) {
|
||||
auth().registration().postKeysForKeyConnectorRegistration(
|
||||
keyConnectorUrl = keyConnectorUrl,
|
||||
ssoOrgIdentifier = ssoOrganizationIdentifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +87,8 @@ class AuthSdkSourceImpl(
|
||||
deviceIdentifier: String,
|
||||
shouldTrustDevice: Boolean,
|
||||
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.registration()
|
||||
.postKeysForTdeRegistration(
|
||||
withContext(context = dispatcherManager.io) {
|
||||
getClient(userId = userId).auth().registration().postKeysForTdeRegistration(
|
||||
request = TdeRegistrationRequest(
|
||||
orgId = organizationId,
|
||||
orgPublicKey = organizationPublicKey,
|
||||
@@ -92,6 +97,7 @@ class AuthSdkSourceImpl(
|
||||
trustDevice = shouldTrustDevice,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun postKeysForUserPasswordRegistration(
|
||||
@@ -101,23 +107,25 @@ class AuthSdkSourceImpl(
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String,
|
||||
): Result<UserMasterPasswordRegistrationResponse> = runCatchingWithLogs {
|
||||
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,
|
||||
),
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,4 +229,16 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -19,8 +20,10 @@ object AuthSdkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthSdkSource(
|
||||
dispatcherManager: DispatcherManager,
|
||||
sdkClientManager: SdkClientManager,
|
||||
): AuthSdkSource = AuthSdkSourceImpl(
|
||||
dispatcherManager = dispatcherManager,
|
||||
sdkClientManager = sdkClientManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -17,7 +16,6 @@ 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].
|
||||
@@ -27,7 +25,6 @@ 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,
|
||||
@@ -97,26 +94,24 @@ class KeyConnectorManagerImpl(
|
||||
organizationIdentifier: String,
|
||||
): Result<MigrateNewUserToKeyConnectorResult> =
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
|
||||
withContext(dispatcherManager.io) {
|
||||
authSdkSource
|
||||
.postKeysForKeyConnectorRegistration(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
keyConnectorUrl = url,
|
||||
ssoOrganizationIdentifier = organizationIdentifier,
|
||||
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,
|
||||
)
|
||||
.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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.policies.PolicyView
|
||||
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: PolicyTypeJson,
|
||||
): List<SyncResponseJson.Policy> = policyManager.getUserPolicies(
|
||||
policyType: PolicyType,
|
||||
): List<PolicyView> = policyManager.getUserPolicies(
|
||||
userId = userId,
|
||||
type = policyType,
|
||||
)
|
||||
|
||||
@@ -90,14 +90,12 @@ object AuthManagerModule {
|
||||
authSdkSource: AuthSdkSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): KeyConnectorManager =
|
||||
KeyConnectorManagerImpl(
|
||||
accountsService = accountsService,
|
||||
authSdkSource = authSdkSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -357,6 +358,11 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -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,7 +38,6 @@ 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
|
||||
@@ -52,6 +51,8 @@ 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
|
||||
@@ -73,6 +74,7 @@ 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
|
||||
@@ -107,6 +109,7 @@ 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
|
||||
@@ -150,7 +153,6 @@ 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
|
||||
@@ -187,7 +189,7 @@ class AuthRepositoryImpl(
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
@@ -310,6 +312,7 @@ class AuthRepositoryImpl(
|
||||
override val organizations: List<Organization>
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getOrganizations(it) }
|
||||
?.filter { it.status == OrganizationStatusType.CONFIRMED }
|
||||
.orEmpty()
|
||||
.toOrganizations()
|
||||
|
||||
@@ -364,7 +367,7 @@ class AuthRepositoryImpl(
|
||||
|
||||
// When the policies for the user have been set, complete the login process.
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
|
||||
.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
|
||||
.onEach { policies ->
|
||||
val userId = activeUserId ?: return@onEach
|
||||
|
||||
@@ -561,15 +564,14 @@ class AuthRepositoryImpl(
|
||||
): Result<VaultUnlockResult> {
|
||||
val userId = profile.userId
|
||||
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
|
||||
return withContext(dispatcherManager.io) {
|
||||
authSdkSource.postKeysForTdeRegistration(
|
||||
return 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)
|
||||
@@ -972,15 +974,14 @@ class AuthRepositoryImpl(
|
||||
return RegisterResult.WeakPassword
|
||||
}
|
||||
if (featureFlagManager.getFeatureFlag(key = FlagKey.V2EncryptionPassword)) {
|
||||
return withContext(dispatcherManager.io) {
|
||||
authSdkSource.postKeysForUserPasswordRegistration(
|
||||
return authSdkSource
|
||||
.postKeysForUserPasswordRegistration(
|
||||
email = email,
|
||||
salt = email,
|
||||
masterPassword = masterPassword,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
emailVerificationToken = emailVerificationToken,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { RegisterResult.Success },
|
||||
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
|
||||
@@ -1056,7 +1057,7 @@ class AuthRepositoryImpl(
|
||||
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
|
||||
val keyConnectorUrl = organizations
|
||||
.find {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.isKeyConnectorEnabled &&
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
}
|
||||
@@ -1277,18 +1278,16 @@ class AuthRepositoryImpl(
|
||||
.map { orgKeys -> enrollStatus to orgKeys }
|
||||
}
|
||||
.flatMap { (enrollStatus, orgKeys) ->
|
||||
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,
|
||||
)
|
||||
}
|
||||
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.storeAccountKeys(
|
||||
@@ -1461,6 +1460,20 @@ 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(
|
||||
@@ -1690,7 +1703,7 @@ class AuthRepositoryImpl(
|
||||
*/
|
||||
private suspend fun passwordPassesPolicies(
|
||||
password: String,
|
||||
policies: List<SyncResponseJson.Policy>,
|
||||
policies: List<PolicyView>,
|
||||
): Boolean {
|
||||
// If there are no master password policies that are enabled and should be
|
||||
// enforced on login, the check should complete.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
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()
|
||||
}
|
||||
@@ -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 shouldUseKeyConnector Indicates that the organization uses a key connector.
|
||||
* @property isKeyConnectorEnabled 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 shouldUseKeyConnector: Boolean,
|
||||
val isKeyConnectorEnabled: Boolean,
|
||||
val role: OrganizationType,
|
||||
val keyConnectorUrl: String?,
|
||||
val userIsClaimedByOrganization: Boolean,
|
||||
|
||||
@@ -42,7 +42,12 @@ 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.
|
||||
* @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 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.
|
||||
@@ -66,6 +71,7 @@ data class UserState(
|
||||
val avatarColorHex: String,
|
||||
val environment: Environment,
|
||||
val isPremium: Boolean,
|
||||
val isPremiumFromSelf: Boolean,
|
||||
val isLoggedIn: Boolean,
|
||||
val isVaultUnlocked: Boolean,
|
||||
val needsPasswordReset: Boolean,
|
||||
@@ -113,6 +119,7 @@ data class UserState(
|
||||
avatarColorHex = "".toHexColorRepresentation(),
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isPremiumFromSelf = false,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
needsPasswordReset = false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -27,6 +28,7 @@ val AuthDiskSource.userOrganizationsList: List<UserOrganizations>
|
||||
userId = userId,
|
||||
organizations = this
|
||||
.getOrganizations(userId = userId)
|
||||
?.filter { it.status == OrganizationStatusType.CONFIRMED }
|
||||
.orEmpty()
|
||||
.toOrganizations(),
|
||||
)
|
||||
@@ -48,10 +50,15 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
|
||||
.map { (userId, _) ->
|
||||
this
|
||||
.getOrganizationsFlow(userId = userId)
|
||||
.map {
|
||||
.map { organizations ->
|
||||
UserOrganizations(
|
||||
userId = userId,
|
||||
organizations = it.orEmpty().toOrganizations(),
|
||||
organizations = organizations
|
||||
?.filter {
|
||||
it.status == OrganizationStatusType.CONFIRMED
|
||||
}
|
||||
.orEmpty()
|
||||
.toOrganizations(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
)
|
||||
@@ -31,7 +31,8 @@ fun GetTokenResponseJson.Success.toUserState(
|
||||
stamp = null,
|
||||
organizationId = null,
|
||||
avatarColorHex = null,
|
||||
hasPremium = jwtTokenData.hasPremium,
|
||||
hasPremiumPersonally = jwtTokenData.hasPremium,
|
||||
hasPremiumFromOrganization = null,
|
||||
forcePasswordResetReason = this.toForcePasswordResetReason(),
|
||||
kdfType = this.kdfType,
|
||||
kdfIterations = this.kdfIterations,
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
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.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
|
||||
@@ -21,7 +34,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = it,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
isKeyConnectorEnabled = this.isKeyConnectorEnabled,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
@@ -39,28 +52,164 @@ fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organiza
|
||||
this.mapNotNull { it.toOrganization() }
|
||||
|
||||
/**
|
||||
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
|
||||
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
|
||||
* [ProfileOrganization]s.
|
||||
*/
|
||||
val SyncResponseJson.Policy.policyInformation: PolicyInformation?
|
||||
get() = data?.toString()?.let {
|
||||
@Suppress("MaxLineLength")
|
||||
fun List<SyncResponseJson.Profile.Organization>.toSdkProfileOrganizations(): List<ProfileOrganization> =
|
||||
this.mapNotNull { it.toSdkProfileOrganization() }
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
PolicyTypeJson.MASTER_PASSWORD -> {
|
||||
PolicyType.MASTER_PASSWORD -> {
|
||||
JSON.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
|
||||
}
|
||||
|
||||
PolicyTypeJson.PASSWORD_GENERATOR -> {
|
||||
PolicyType.PASSWORD_GENERATOR -> {
|
||||
JSON.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
|
||||
}
|
||||
|
||||
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
|
||||
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
|
||||
JSON.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
|
||||
}
|
||||
|
||||
PolicyTypeJson.SEND_OPTIONS -> {
|
||||
PolicyType.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ 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
|
||||
@@ -83,7 +84,8 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
.copy(
|
||||
avatarColorHex = syncProfile.avatarColor,
|
||||
stamp = syncProfile.securityStamp,
|
||||
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
|
||||
hasPremiumPersonally = syncProfile.isPremium,
|
||||
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
|
||||
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
|
||||
creationDate = syncProfile.creationDate,
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
@@ -191,7 +193,7 @@ fun UserStateJson.toUserState(
|
||||
isBiometricsEnabledProvider: (userId: String) -> Boolean,
|
||||
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
|
||||
isDeviceTrustedProvider: (userId: String) -> Boolean,
|
||||
getUserPolicies: (userId: String, policy: PolicyTypeJson) -> List<SyncResponseJson.Policy>,
|
||||
getUserPolicies: (userId: String, policy: PolicyType) -> List<PolicyView>,
|
||||
): UserState =
|
||||
UserState(
|
||||
activeUserId = this.activeUserId,
|
||||
@@ -234,15 +236,15 @@ fun UserStateJson.toUserState(
|
||||
|
||||
val hasPersonalOwnershipRestrictedOrg = getUserPolicies(
|
||||
userId,
|
||||
PolicyTypeJson.PERSONAL_OWNERSHIP,
|
||||
PolicyType.ORGANIZATION_DATA_OWNERSHIP,
|
||||
)
|
||||
.any { it.isEnabled }
|
||||
.any { it.enabled }
|
||||
|
||||
val hasPersonalVaultExportRestrictedOrg = getUserPolicies(
|
||||
userId,
|
||||
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT,
|
||||
PolicyType.DISABLE_PERSONAL_VAULT_EXPORT,
|
||||
)
|
||||
.any { it.isEnabled }
|
||||
.any { it.enabled }
|
||||
|
||||
UserState.Account(
|
||||
userId = userId,
|
||||
@@ -253,7 +255,9 @@ fun UserStateJson.toUserState(
|
||||
.settings
|
||||
.environmentUrlData
|
||||
.toEnvironmentUrlsOrDefault(),
|
||||
isPremium = profile.hasPremium == true,
|
||||
isPremium = profile.hasPremiumPersonally == true ||
|
||||
profile.hasPremiumFromOrganization == true,
|
||||
isPremiumFromSelf = profile.hasPremiumPersonally == true,
|
||||
isLoggedIn = userAccountTokens
|
||||
.find { it.userId == userId }
|
||||
?.isLoggedIn == true,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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(
|
||||
@@ -15,6 +16,7 @@ data class Browser(
|
||||
) : this(
|
||||
packageName = packageName,
|
||||
possibleUrlFieldIds = listOf(urlFieldId),
|
||||
possibleUrlSemanticIds = emptyList(),
|
||||
urlExtractor = urlExtractor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,22 +43,34 @@ class AccessibilityParserImpl(
|
||||
return browser
|
||||
.possibleUrlFieldIds
|
||||
.flatMap { viewId ->
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
.firstOrNull()
|
||||
?.toUriOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ 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.
|
||||
@@ -36,14 +45,21 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
|
||||
Browser(packageName = "com.cookiegames.smartcookie", urlFieldId = "search"),
|
||||
Browser(
|
||||
packageName = "com.cookiejarapps.android.smartcookieweb",
|
||||
urlFieldId = "mozac_browser_toolbar_url_view",
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
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", urlFieldId = "mozac_browser_toolbar_url_view"),
|
||||
Browser(
|
||||
packageName = "com.iode.firefox",
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(packageName = "com.jamal2367.styx", urlFieldId = "search"),
|
||||
Browser(packageName = "com.kiwibrowser.browser", urlFieldId = "url_bar"),
|
||||
Browser(packageName = "com.kiwibrowser.browser.dev", urlFieldId = "url_bar"),
|
||||
@@ -67,7 +83,12 @@ 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"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(packageName = "com.rainsee.create", urlFieldId = "search_box"),
|
||||
Browser(packageName = "com.sec.android.app.sbrowser", urlFieldId = "location_bar_edit_text"),
|
||||
@@ -102,7 +123,9 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
|
||||
Browser(packageName = "idm.internet.download.manager.plus", urlFieldId = "search"),
|
||||
Browser(
|
||||
packageName = "io.github.forkmaintainers.iceraven",
|
||||
urlFieldId = "mozac_browser_toolbar_url_view",
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(packageName = "mark.via", urlFieldId = "am,an"),
|
||||
Browser(packageName = "mark.via.gp", urlFieldId = "as"),
|
||||
@@ -129,78 +152,155 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
|
||||
Browser(
|
||||
packageName = "org.gnu.icecat",
|
||||
// 2nd = Anticipation
|
||||
possibleUrlFieldIds = listOf("url_bar_title", "mozac_browser_toolbar_url_view"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"url_bar_title",
|
||||
"mozac_browser_toolbar_url_view",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.ironfoxoss.ironfox",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.ironfoxoss.ironfox.nightly",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
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,
|
||||
),
|
||||
Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"),
|
||||
// [DEPRECATED ENTRY]
|
||||
Browser(
|
||||
packageName = "org.mozilla.fenix.nightly",
|
||||
urlFieldId = "mozac_browser_toolbar_url_view",
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
// [DEPRECATED ENTRY]
|
||||
Browser(
|
||||
packageName = "org.mozilla.fennec_aurora",
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.fennec_fdroid",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.firefox",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.firefox_beta",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.focus",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"display_url",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.focus.beta",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"display_url",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.focus.nightly",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"display_url",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.klar",
|
||||
// 2nd = Legacy
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"display_url",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.mozilla.reference.browser",
|
||||
urlFieldId = "mozac_browser_toolbar_url_view",
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
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"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(
|
||||
packageName = "org.torproject.torbrowser_alpha",
|
||||
// 2nd = Legacy (before v10.0a8)
|
||||
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
|
||||
possibleUrlFieldIds = listOf(
|
||||
"mozac_browser_toolbar_url_view",
|
||||
"url_bar_title",
|
||||
),
|
||||
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
|
||||
urlExtractor = mozillaUrlExtractor,
|
||||
),
|
||||
Browser(packageName = "org.ungoogled.chromium.extensions.stable", urlFieldId = "url_bar"),
|
||||
Browser(packageName = "org.ungoogled.chromium.stable", urlFieldId = "url_bar"),
|
||||
|
||||
@@ -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.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
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(PolicyTypeJson.PERSONAL_OWNERSHIP).any()) {
|
||||
if (policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any()) {
|
||||
saveCallback.onSuccess()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.autofill.provider
|
||||
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
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(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.getActivePolicies(PolicyType.RESTRICTED_ITEM_TYPES)
|
||||
.map { it.organizationId }
|
||||
return cipherListViews
|
||||
.mapNotNull { cipherListView ->
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -14,6 +13,7 @@ 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,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,34 @@ 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.
|
||||
*/
|
||||
@@ -42,4 +72,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -3,27 +3,37 @@ 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.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
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
|
||||
@@ -33,14 +43,14 @@ import java.time.Instant
|
||||
/**
|
||||
* Default implementation of [PremiumStateManager].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
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,
|
||||
@@ -48,10 +58,74 @@ 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(
|
||||
authRepository.userStateFlow,
|
||||
authDiskSource.userStateFlow,
|
||||
billingRepository.isInAppBillingSupportedFlow,
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
|
||||
authDiskSource.activeUserIdChangesFlow
|
||||
@@ -72,21 +146,71 @@ class PremiumStateManagerImpl(
|
||||
isDismissed,
|
||||
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,
|
||||
BannerInputs(
|
||||
userState = userState,
|
||||
isInAppBillingSupported = isInAppBillingSupported,
|
||||
featureFlagEnabled = featureFlagEnabled,
|
||||
isDismissed = isDismissed,
|
||||
vaultDataState = vaultDataState,
|
||||
)
|
||||
val itemCount = vaultDataState.activeVaultItemCount()
|
||||
}
|
||||
.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()
|
||||
)
|
||||
|
||||
!isPremium &&
|
||||
isInAppBillingSupported &&
|
||||
featureFlagEnabled &&
|
||||
!isDismissed &&
|
||||
isAccountOldEnough &&
|
||||
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
|
||||
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
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
@@ -94,6 +218,12 @@ 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
|
||||
@@ -109,7 +239,14 @@ class PremiumStateManagerImpl(
|
||||
settingsDiskSource
|
||||
.getUpgradedToPremiumCardConsumedFlow(userId)
|
||||
.map { it ?: false },
|
||||
) { isPending, isConsumed -> isPending && !isConsumed }
|
||||
authDiskSource
|
||||
.userStateFlow
|
||||
.map {
|
||||
it?.activeAccount?.profile?.hasPremiumPersonally == true
|
||||
},
|
||||
) { isPending, isConsumed, isPremiumFromSelf ->
|
||||
isPending && !isConsumed && isPremiumFromSelf
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -128,33 +265,41 @@ 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 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
|
||||
// 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
|
||||
.userStateFlow
|
||||
.map { state ->
|
||||
state?.activeAccount?.let { it.userId to it.isPremium }
|
||||
state?.activeAccount?.profile?.let {
|
||||
it.userId to (it.hasPremiumPersonally == true)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.scanPairs()
|
||||
.onEach { (previous, current) ->
|
||||
if (current == null) return@onEach
|
||||
val (currentUserId, currentIsPremium) = current
|
||||
if (!currentIsPremium) return@onEach
|
||||
// Same user transitioning from non-premium to premium counts as an upgrade.
|
||||
val (currentUserId, currentIsPremiumFromSelf) = current
|
||||
if (!currentIsPremiumFromSelf) return@onEach
|
||||
// Same user transitioning from non-personal-premium to personal-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)
|
||||
@@ -179,6 +324,35 @@ 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) {
|
||||
@@ -189,8 +363,58 @@ 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`.
|
||||
|
||||
@@ -32,7 +32,9 @@ interface BillingRepository {
|
||||
suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult
|
||||
|
||||
/**
|
||||
* Fetches the current user's premium subscription details.
|
||||
* 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].
|
||||
*/
|
||||
suspend fun getSubscription(): SubscriptionResult
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -54,10 +55,14 @@ class BillingRepositoryImpl(
|
||||
billingService
|
||||
.getSubscription()
|
||||
.fold(
|
||||
onSuccess = {
|
||||
SubscriptionResult.Success(
|
||||
subscription = it.toSubscriptionInfo(),
|
||||
)
|
||||
onSuccess = { response ->
|
||||
when (response) {
|
||||
is GetSubscriptionResponse.Success -> SubscriptionResult.Success(
|
||||
subscription = response.subscription.toSubscriptionInfo(),
|
||||
)
|
||||
|
||||
is GetSubscriptionResponse.NotFound -> SubscriptionResult.NotFound
|
||||
}
|
||||
},
|
||||
onFailure = { SubscriptionResult.Error(error = it) },
|
||||
)
|
||||
|
||||
@@ -6,7 +6,19 @@ package com.x8bit.bitwarden.data.billing.repository.model
|
||||
enum class PremiumSubscriptionStatus {
|
||||
ACTIVE,
|
||||
CANCELED,
|
||||
OVERDUE_PAYMENT,
|
||||
|
||||
/**
|
||||
* 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,
|
||||
PAST_DUE,
|
||||
PAUSED,
|
||||
UPDATE_PAYMENT,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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.
|
||||
@@ -30,6 +32,7 @@ data class SubscriptionInfo(
|
||||
val estimatedTax: BigDecimal,
|
||||
val nextChargeTotal: BigDecimal,
|
||||
val nextCharge: Instant?,
|
||||
val cancelAt: Instant?,
|
||||
val canceledDate: Instant?,
|
||||
val suspensionDate: Instant?,
|
||||
val gracePeriodDays: Int?,
|
||||
|
||||
@@ -11,6 +11,13 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -11,33 +12,40 @@ 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.
|
||||
*
|
||||
* `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
|
||||
* 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
|
||||
* does not expose a precomputed total.
|
||||
*/
|
||||
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
|
||||
val seatsCost = cart.passwordManager.seats.cost
|
||||
val storageCost = cart.passwordManager.additionalStorage?.cost
|
||||
val discountAmount = cart.discount?.toMoneyAmount(
|
||||
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
|
||||
)
|
||||
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 estimatedTax = cart.estimatedTax
|
||||
val nextChargeTotal = seatsCost +
|
||||
(storageCost ?: BigDecimal.ZERO) -
|
||||
val nextChargeTotal = subtotal -
|
||||
(discountAmount ?: BigDecimal.ZERO) +
|
||||
estimatedTax
|
||||
|
||||
return SubscriptionInfo(
|
||||
status = status.toPremiumSubscriptionStatus(),
|
||||
status = toPremiumSubscriptionStatus(),
|
||||
cadence = cart.cadence.toPlanCadence(),
|
||||
seatsCost = seatsCost,
|
||||
storageCost = storageCost,
|
||||
@@ -45,41 +53,49 @@ fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
|
||||
estimatedTax = estimatedTax,
|
||||
nextChargeTotal = nextChargeTotal,
|
||||
nextCharge = nextCharge,
|
||||
cancelAt = cancelAt,
|
||||
canceledDate = canceled,
|
||||
suspensionDate = suspension,
|
||||
gracePeriodDays = gracePeriod,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
|
||||
PremiumSubscriptionStatus = when (status) {
|
||||
SubscriptionStatusJson.ACTIVE,
|
||||
SubscriptionStatusJson.TRIALING,
|
||||
-> {
|
||||
if (cancelAt != null) {
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION
|
||||
} else {
|
||||
PremiumSubscriptionStatus.ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
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.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
|
||||
private fun BitwardenDiscountJson.toDiscountAmount(baseAmount: BigDecimal): BigDecimal =
|
||||
when (type) {
|
||||
DiscountTypeJson.AMOUNT_OFF -> value
|
||||
DiscountTypeJson.PERCENT_OFF ->
|
||||
subtotal
|
||||
.multiply(value)
|
||||
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
|
||||
DiscountTypeJson.PERCENT_OFF -> {
|
||||
val percentage = if (value < BigDecimal.ONE) value else value.movePointLeft(2)
|
||||
baseAmount.multiply(percentage).setScale(MONEY_SCALE, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ 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
|
||||
@@ -65,12 +64,10 @@ class BitwardenCredentialManagerImpl(
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val cipherMatchingManager: CipherMatchingManager,
|
||||
private val passkeyAttestationOptionsSanitizer: PasskeyAttestationOptionsSanitizer,
|
||||
dispatcherManager: DispatcherManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : BitwardenCredentialManager,
|
||||
Fido2CredentialStore by fido2CredentialStore {
|
||||
|
||||
private val ioScope = CoroutineScope(dispatcherManager.io)
|
||||
|
||||
override var isUserVerified: Boolean = false
|
||||
|
||||
override var authenticationAttempts: Int = 0
|
||||
@@ -179,7 +176,7 @@ class BitwardenCredentialManagerImpl(
|
||||
|
||||
override suspend fun getCredentialEntries(
|
||||
getCredentialsRequest: GetCredentialsRequest,
|
||||
): Result<List<CredentialEntry>> = withContext(ioScope.coroutineContext) {
|
||||
): Result<List<CredentialEntry>> = withContext(dispatcherManager.io) {
|
||||
val cipherListViews = vaultRepository
|
||||
.decryptCipherListResultStateFlow
|
||||
.takeUntilLoaded()
|
||||
|
||||
@@ -39,7 +39,7 @@ private const val RELEASE_BUILD = "release"
|
||||
class PrivilegedAppRepositoryImpl(
|
||||
private val privilegedAppDiskSource: PrivilegedAppDiskSource,
|
||||
private val assetManager: AssetManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
private val json: Json,
|
||||
) : PrivilegedAppRepository {
|
||||
|
||||
@@ -118,7 +118,7 @@ class PrivilegedAppRepositoryImpl(
|
||||
.toPrivilegedAppAllowListJson()
|
||||
|
||||
override suspend fun getGoogleTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? =
|
||||
withContext(ioScope.coroutineContext) {
|
||||
withContext(dispatcherManager.io) {
|
||||
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(ioScope.coroutineContext) {
|
||||
return withContext(dispatcherManager.io) {
|
||||
assetManager
|
||||
.readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
|
||||
.map { json.decodeFromStringOrNull<PrivilegedAppAllowListJson>(it) }
|
||||
|
||||
@@ -40,6 +40,17 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
*/
|
||||
var initialAutofillDialogShown: Boolean?
|
||||
|
||||
/**
|
||||
* Indicates if the accessibility disclaimer has been displayed to the user.
|
||||
*/
|
||||
var hasShownAccessibilityDisclaimer: Boolean?
|
||||
|
||||
/**
|
||||
* Emits up-to-date values indicating if the accessibility disclaimer has been displayed to
|
||||
* the user.
|
||||
*/
|
||||
val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* The currently persisted app theme (or `null` if not set).
|
||||
*/
|
||||
@@ -182,6 +193,25 @@ 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].
|
||||
|
||||
@@ -35,6 +35,7 @@ private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "accountBiometricInteg
|
||||
private const val CRASH_LOGGING_ENABLED_KEY = "crashLoggingEnabled"
|
||||
private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "clearClipboard"
|
||||
private const val INITIAL_AUTOFILL_DIALOG_SHOWN = "addSitePromptShown"
|
||||
private const val HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY = "hasShownAccessibilityDisclaimer"
|
||||
private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedInOrCreatedAccount"
|
||||
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
|
||||
private const val SHOW_BROWSER_AUTOFILL_SETTING_BADGE = "showBrowserAutofillSettingBadge"
|
||||
@@ -57,11 +58,13 @@ 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")
|
||||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
class SettingsDiskSourceImpl(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
@@ -107,6 +110,9 @@ 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?>()
|
||||
@@ -123,6 +129,8 @@ class SettingsDiskSourceImpl(
|
||||
|
||||
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
init {
|
||||
migrateScreenCaptureSetting()
|
||||
}
|
||||
@@ -162,6 +170,17 @@ class SettingsDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override var hasShownAccessibilityDisclaimer: Boolean?
|
||||
set(value) {
|
||||
putBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY, value)
|
||||
mutableHasShownAccessibilityDisclaimerFlow.tryEmit(value)
|
||||
}
|
||||
get() = getBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY)
|
||||
|
||||
override val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
|
||||
get() = mutableHasShownAccessibilityDisclaimerFlow
|
||||
.onSubscription { emit(hasShownAccessibilityDisclaimer) }
|
||||
|
||||
override var systemBiometricIntegritySource: String?
|
||||
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
|
||||
set(value) {
|
||||
@@ -264,6 +283,8 @@ 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
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
|
||||
@@ -346,6 +367,26 @@ 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,
|
||||
@@ -711,6 +752,13 @@ 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?> =
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -58,6 +59,7 @@ object PlatformNetworkModule {
|
||||
certificateManager: CertificateManager,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
networkCookieManager: NetworkCookieManager,
|
||||
networkPermissionManager: NetworkPermissionManager,
|
||||
clock: Clock,
|
||||
): BitwardenServiceClientConfig = BitwardenServiceClientConfig(
|
||||
clock = clock,
|
||||
@@ -72,6 +74,7 @@ object PlatformNetworkModule {
|
||||
certificateProvider = certificateManager,
|
||||
enableHttpBodyLogging = buildInfoManager.isDevBuild,
|
||||
cookieProvider = networkCookieManager,
|
||||
permissionProvider = networkPermissionManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -11,6 +11,11 @@ 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].
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.policies.PolicyView
|
||||
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: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>>
|
||||
fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>>
|
||||
|
||||
/**
|
||||
* Get all the policies of the given [type] that are enabled and applicable to the user.
|
||||
*/
|
||||
fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy>
|
||||
fun getActivePolicies(type: PolicyType): List<PolicyView>
|
||||
|
||||
/**
|
||||
* Get all the policies of the given [type] that are enabled and applicable to the [userId].
|
||||
*/
|
||||
fun getUserPolicies(
|
||||
userId: String,
|
||||
type: PolicyTypeJson,
|
||||
): List<SyncResponseJson.Policy>
|
||||
type: PolicyType,
|
||||
): List<PolicyView>
|
||||
|
||||
/**
|
||||
* Get the organization id of the personal ownership policy.
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
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.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.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.mapNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* The default [PolicyManager] implementation. This class is responsible for
|
||||
@@ -19,114 +26,151 @@ import kotlinx.coroutines.flow.mapNotNull
|
||||
*/
|
||||
class PolicyManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val authSdkSource: AuthSdkSource,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
) : PolicyManager {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getActivePoliciesFlow(type: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>> =
|
||||
override fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>> =
|
||||
authDiskSource
|
||||
.activeUserIdChangesFlow
|
||||
.flatMapLatest { activeUserId ->
|
||||
activeUserId
|
||||
?.let { userId ->
|
||||
authDiskSource
|
||||
.getPoliciesFlow(userId)
|
||||
.mapNotNull {
|
||||
filterPolicies(
|
||||
userId = userId,
|
||||
type = type,
|
||||
policies = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
?.let { userId -> getAppliedPolicyViewsFlow(userId = userId, type = type) }
|
||||
?: emptyFlow()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
override fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy> =
|
||||
override fun getActivePolicies(type: PolicyType): List<PolicyView> =
|
||||
authDiskSource
|
||||
.userState
|
||||
?.activeUserId
|
||||
?.let { userId ->
|
||||
filterPolicies(
|
||||
userId = userId,
|
||||
type = type,
|
||||
policies = authDiskSource.getPolicies(userId = userId),
|
||||
)
|
||||
}
|
||||
?: emptyList()
|
||||
?.let { userId -> getUserPolicies(userId = userId, type = type) }
|
||||
.orEmpty()
|
||||
|
||||
override fun getUserPolicies(
|
||||
userId: String,
|
||||
type: PolicyTypeJson,
|
||||
): List<SyncResponseJson.Policy> =
|
||||
type: PolicyType,
|
||||
): List<PolicyView> =
|
||||
this
|
||||
.filterPolicies(
|
||||
userId = userId,
|
||||
type = type,
|
||||
policies = authDiskSource.getPolicies(userId = userId),
|
||||
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),
|
||||
)
|
||||
.orEmpty()
|
||||
|
||||
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
|
||||
this
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.getActivePolicies(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
|
||||
.sortedBy { it.revisionDate }
|
||||
.firstOrNull()
|
||||
?.organizationId
|
||||
|
||||
/**
|
||||
* A helper method to filter policies.
|
||||
*/
|
||||
private fun filterPolicies(
|
||||
private fun getAppliedPolicyViewsFlow(
|
||||
userId: String,
|
||||
type: PolicyTypeJson,
|
||||
policies: List<SyncResponseJson.Policy>?,
|
||||
): List<SyncResponseJson.Policy>? {
|
||||
policies ?: return null
|
||||
if (policies.isEmpty()) return emptyList()
|
||||
|
||||
// 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)
|
||||
}
|
||||
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()
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper method to determine if the organization is exempt from policies.
|
||||
*/
|
||||
private fun isOrganizationExemptFromPolicies(
|
||||
organization: SyncResponseJson.Profile.Organization,
|
||||
policyType: PolicyTypeJson,
|
||||
private fun OrganizationPolicyData.isOrganizationExemptFromPolicies(
|
||||
policyType: PolicyType,
|
||||
): Boolean =
|
||||
when (policyType) {
|
||||
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
|
||||
organization.type == OrganizationType.OWNER
|
||||
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
|
||||
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER
|
||||
}
|
||||
|
||||
PolicyTypeJson.PASSWORD_GENERATOR,
|
||||
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN,
|
||||
PolicyTypeJson.RESTRICT_ITEM_TYPES,
|
||||
-> {
|
||||
false
|
||||
}
|
||||
PolicyType.PASSWORD_GENERATOR,
|
||||
PolicyType.REMOVE_UNLOCK_WITH_PIN,
|
||||
PolicyType.RESTRICTED_ITEM_TYPES,
|
||||
-> false
|
||||
|
||||
else -> {
|
||||
(organization.type == OrganizationType.OWNER ||
|
||||
organization.type == OrganizationType.ADMIN) ||
|
||||
organization.permissions.shouldManagePolicies
|
||||
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER ||
|
||||
this.organizationUserPolicyContext.role == OrganizationUserType.ADMIN ||
|
||||
this.organizationShouldUsePolicies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class OrganizationPolicyData(
|
||||
val organizationUserPolicyContext: OrganizationUserPolicyContext,
|
||||
val organizationShouldUsePolicies: Boolean,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,13 @@ 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.
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
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,
|
||||
@@ -38,7 +45,9 @@ class SdkClientManagerImpl(
|
||||
}
|
||||
},
|
||||
) : SdkClientManager {
|
||||
private val userIdToClientMap = mutableMapOf<String, Client>()
|
||||
private val userIdToClientMap = concurrentMapOf<String, Client>()
|
||||
private val ioScope = CoroutineScope(context = dispatcherManager.io)
|
||||
private val globalClientDeferred: Deferred<Client>
|
||||
|
||||
init {
|
||||
// The SDK requires access to Android APIs that were not made public until API 31. In order
|
||||
@@ -47,8 +56,13 @@ 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) }
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -74,6 +75,8 @@ 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
|
||||
@@ -218,11 +221,13 @@ 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,
|
||||
@@ -260,8 +265,12 @@ object PlatformManagerModule {
|
||||
@Singleton
|
||||
fun providePolicyManager(
|
||||
authDiskSource: AuthDiskSource,
|
||||
authSdkSource: AuthSdkSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
): PolicyManager = PolicyManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
authSdkSource = authSdkSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -450,4 +459,14 @@ object PlatformManagerModule {
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkPermissionManager(
|
||||
@ApplicationContext context: Context,
|
||||
resourceManager: ResourceManager,
|
||||
): NetworkPermissionManager = NetworkPermissionManagerImpl(
|
||||
context = context,
|
||||
resourceManager = resourceManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +144,13 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.util
|
||||
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
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 = getPolicyTypeJson<T>())
|
||||
.getActivePolicies(type = getPolicyType<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 = getPolicyTypeJson<T>())
|
||||
.getActivePoliciesFlow(type = getPolicyType<T>())
|
||||
.map { policies ->
|
||||
policies.mapNotNull { policy -> policy.policyInformation as? T }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyTypeJson]
|
||||
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyType]
|
||||
* counterpart.
|
||||
*/
|
||||
inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson =
|
||||
inline fun <reified T : PolicyInformation> getPolicyType(): PolicyType =
|
||||
when (T::class.java) {
|
||||
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
|
||||
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
|
||||
|
||||
else -> {
|
||||
throw IllegalStateException(
|
||||
@@ -43,9 +43,10 @@ inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson =
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for verifying if user has enabled the restrict item policy.
|
||||
*/
|
||||
fun PolicyManager.hasRestrictItemTypes(): Boolean =
|
||||
getActivePolicies(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.any { it.isEnabled }
|
||||
getActivePolicies(type = PolicyType.RESTRICTED_ITEM_TYPES)
|
||||
.any { it.enabled }
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -104,11 +103,6 @@ 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,
|
||||
|
||||
@@ -187,6 +187,16 @@ interface SettingsRepository : FlightRecorderManager {
|
||||
*/
|
||||
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Whether the accessibility disclaimer has been displayed to the user.
|
||||
*/
|
||||
val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Stores that the accessibility disclaimer has been displayed to the user.
|
||||
*/
|
||||
fun accessibilityDisclaimerHasBeenShown()
|
||||
|
||||
/**
|
||||
* Disables autofill if it is currently enabled.
|
||||
*/
|
||||
|
||||
@@ -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.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.policies.PolicyView
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
@@ -372,13 +372,27 @@ class SettingsRepositoryImpl(
|
||||
initialValue = isScreenCaptureAllowed,
|
||||
)
|
||||
|
||||
override val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
|
||||
get() = settingsDiskSource
|
||||
.hasShownAccessibilityDisclaimerFlow
|
||||
.map { it ?: false }
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Lazily,
|
||||
initialValue = settingsDiskSource.hasShownAccessibilityDisclaimer ?: false,
|
||||
)
|
||||
|
||||
init {
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
|
||||
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
|
||||
.onEach { updateVaultUnlockSettingsIfNecessary(it) }
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
override fun accessibilityDisclaimerHasBeenShown() {
|
||||
settingsDiskSource.hasShownAccessibilityDisclaimer = true
|
||||
}
|
||||
|
||||
override fun disableAutofill() {
|
||||
autofillManager.disableAutofillServices()
|
||||
|
||||
@@ -676,7 +690,7 @@ class SettingsRepositoryImpl(
|
||||
* settings to determine whether to update the user's settings.
|
||||
*/
|
||||
private fun updateVaultUnlockSettingsIfNecessary(
|
||||
policies: List<SyncResponseJson.Policy>,
|
||||
policies: List<PolicyView>,
|
||||
) {
|
||||
// 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
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -8,6 +9,7 @@ import com.bitwarden.network.exception.CookieRedirectException
|
||||
*/
|
||||
val Throwable.userFriendlyMessage: String?
|
||||
get() = when (this) {
|
||||
is LocalNetworkAccessException -> message
|
||||
is CookieRedirectException -> message
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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.
|
||||
@@ -14,6 +16,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
* [GeneratorClients] provided by the Bitwarden SDK.
|
||||
*/
|
||||
class GeneratorSdkSourceImpl(
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
sdkClientManager: SdkClientManager,
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
GeneratorSdkSource {
|
||||
@@ -51,6 +54,8 @@ class GeneratorSdkSourceImpl(
|
||||
override suspend fun generateForwardedServiceEmail(
|
||||
request: UsernameGeneratorRequest.Forwarded,
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
useClient { generators().username(request) }
|
||||
withContext(context = dispatcherManager.io) {
|
||||
useClient { generators().username(request) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -19,6 +20,10 @@ object GeneratorSdkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGeneratorSdkSource(
|
||||
dispatcherManager: DispatcherManager,
|
||||
sdkClientManager: SdkClientManager,
|
||||
): GeneratorSdkSource = GeneratorSdkSourceImpl(sdkClientManager = sdkClientManager)
|
||||
): GeneratorSdkSource = GeneratorSdkSourceImpl(
|
||||
dispatcherManager = dispatcherManager,
|
||||
sdkClientManager = sdkClientManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ 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
|
||||
|
||||
@@ -193,8 +192,9 @@ class GeneratorRepositoryImpl(
|
||||
|
||||
override suspend fun generateForwardedServiceUsername(
|
||||
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
|
||||
): GeneratedForwardedServiceUsernameResult = withContext(scope.coroutineContext) {
|
||||
generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
|
||||
): GeneratedForwardedServiceUsernameResult =
|
||||
generatorSdkSource
|
||||
.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
|
||||
.fold(
|
||||
onSuccess = { generatedEmail ->
|
||||
GeneratedForwardedServiceUsernameResult.Success(generatedEmail)
|
||||
@@ -203,7 +203,6 @@ class GeneratorRepositoryImpl(
|
||||
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message, error = it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? {
|
||||
val userId = authDiskSource.userState?.activeUserId
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 {
|
||||
|
||||
@@ -8,10 +8,11 @@ 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.PolicyTypeJson
|
||||
import com.bitwarden.network.model.OrganizationStatusType
|
||||
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
|
||||
@@ -144,6 +145,7 @@ class VaultMigrationManagerImpl(
|
||||
|
||||
val orgName = authDiskSource
|
||||
.getOrganizations(userId = userId)
|
||||
?.filter { it.status == OrganizationStatusType.CONFIRMED }
|
||||
?.firstOrNull { it.id == orgId }
|
||||
?.name
|
||||
?: return@update VaultMigrationData.NoMigrationRequired
|
||||
@@ -167,9 +169,7 @@ class VaultMigrationManagerImpl(
|
||||
hasPersonalCiphers: Boolean,
|
||||
isNetworkConnected: Boolean,
|
||||
): Boolean =
|
||||
policyManager
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.any() &&
|
||||
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any() &&
|
||||
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
|
||||
isNetworkConnected &&
|
||||
hasPersonalCiphers
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -469,6 +470,9 @@ class VaultSyncManagerImpl(
|
||||
data = collections.sortAlphabeticallyByTypeAndOrganization(
|
||||
userOrganizations = authDiskSource
|
||||
.getOrganizations(userId = userId)
|
||||
?.filter { org ->
|
||||
org.status == OrganizationStatusType.CONFIRMED
|
||||
}
|
||||
.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.util
|
||||
|
||||
import com.bitwarden.network.model.OrganizationStatusType
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.organizations.OrganizationUserStatusType
|
||||
import com.bitwarden.organizations.OrganizationUserType
|
||||
import com.bitwarden.policies.OrganizationUserPolicyContext
|
||||
|
||||
/**
|
||||
* Converts a list of network [SyncResponseJson.Profile.Organization] models to a list of SDK
|
||||
* [OrganizationUserPolicyContext].
|
||||
*/
|
||||
@Suppress("MaxLineLength")
|
||||
fun List<SyncResponseJson.Profile.Organization>.toSdkOrganizationPolicyContexts(): List<OrganizationUserPolicyContext> =
|
||||
this.map { it.toSdkOrganizationPolicyContext() }
|
||||
|
||||
/**
|
||||
* Converts a network [SyncResponseJson.Profile.Organization] model to an SDK
|
||||
* [OrganizationUserPolicyContext].
|
||||
*/
|
||||
@Suppress("MaxLineLength")
|
||||
fun SyncResponseJson.Profile.Organization.toSdkOrganizationPolicyContext(): OrganizationUserPolicyContext =
|
||||
OrganizationUserPolicyContext(
|
||||
id = this.id,
|
||||
status = this.status.toSdkOrganizationUserStatusType,
|
||||
role = this.type.toSdkOrganizationUserType,
|
||||
enabled = this.isEnabled,
|
||||
usePolicies = this.shouldUsePolicies,
|
||||
isProviderUser = this.isProviderUser,
|
||||
)
|
||||
|
||||
private val OrganizationStatusType.toSdkOrganizationUserStatusType: OrganizationUserStatusType
|
||||
get() = when (this) {
|
||||
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
|
||||
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
|
||||
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
|
||||
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
|
||||
}
|
||||
|
||||
private val OrganizationType.toSdkOrganizationUserType: OrganizationUserType
|
||||
get() = when (this) {
|
||||
OrganizationType.OWNER -> OrganizationUserType.OWNER
|
||||
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
|
||||
OrganizationType.USER -> OrganizationUserType.USER
|
||||
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.util
|
||||
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.policies.PolicyView
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Converts a list of network [SyncResponseJson.Policy] models to a list of SDK [PolicyView].
|
||||
*/
|
||||
fun List<SyncResponseJson.Policy>.toSdkPolicyViews(): List<PolicyView> =
|
||||
this.map { it.toSdkPolicyView() }
|
||||
|
||||
/**
|
||||
* Converts a network [SyncResponseJson.Policy] model to an SDK [PolicyView].
|
||||
*/
|
||||
private fun SyncResponseJson.Policy.toSdkPolicyView(): PolicyView =
|
||||
PolicyView(
|
||||
organizationId = this.organizationId,
|
||||
id = this.id,
|
||||
type = this.type.toSdkPolicyType,
|
||||
enabled = this.isEnabled,
|
||||
data = this.data?.let { Json.encodeToString(it) },
|
||||
revisionDate = this.revisionDate,
|
||||
)
|
||||
|
||||
private val PolicyTypeJson.toSdkPolicyType: PolicyType
|
||||
get() = when (this) {
|
||||
PolicyTypeJson.TWO_FACTOR_AUTHENTICATION -> PolicyType.TWO_FACTOR_AUTHENTICATION
|
||||
PolicyTypeJson.MASTER_PASSWORD -> PolicyType.MASTER_PASSWORD
|
||||
PolicyTypeJson.PASSWORD_GENERATOR -> PolicyType.PASSWORD_GENERATOR
|
||||
PolicyTypeJson.ONLY_ORG -> PolicyType.SINGLE_ORG
|
||||
PolicyTypeJson.REQUIRE_SSO -> PolicyType.REQUIRE_SSO
|
||||
PolicyTypeJson.PERSONAL_OWNERSHIP -> PolicyType.ORGANIZATION_DATA_OWNERSHIP
|
||||
PolicyTypeJson.DISABLE_SEND -> PolicyType.DISABLE_SEND
|
||||
PolicyTypeJson.SEND_OPTIONS -> PolicyType.SEND_OPTIONS
|
||||
PolicyTypeJson.RESET_PASSWORD -> PolicyType.RESET_PASSWORD
|
||||
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> PolicyType.MAXIMUM_VAULT_TIMEOUT
|
||||
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT -> PolicyType.DISABLE_PERSONAL_VAULT_EXPORT
|
||||
PolicyTypeJson.ACTIVATE_AUTOFILL -> PolicyType.ACTIVATE_AUTOFILL
|
||||
PolicyTypeJson.AUTOMATIC_APP_LOG_IN -> PolicyType.AUTOMATIC_APP_LOG_IN
|
||||
PolicyTypeJson.FREE_FAMILIES_SPONSORSHIP_POLICY -> PolicyType.FREE_FAMILIES_SPONSORSHIP
|
||||
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN -> PolicyType.REMOVE_UNLOCK_WITH_PIN
|
||||
PolicyTypeJson.RESTRICT_ITEM_TYPES -> PolicyType.RESTRICTED_ITEM_TYPES
|
||||
PolicyTypeJson.URI_MATCH_DEFAULTS -> PolicyType.URI_MATCH_DEFAULTS
|
||||
PolicyTypeJson.AUTOTYPE_DEFAULT_SETTING -> PolicyType.AUTOTYPE_DEFAULT_SETTING
|
||||
PolicyTypeJson.AUTOMATIC_USER_CONFIRMATION -> PolicyType.AUTOMATIC_USER_CONFIRMATION
|
||||
PolicyTypeJson.BLOCK_CLAIMED_DOMAIN_ACCOUNT_CREATION -> {
|
||||
PolicyType.BLOCK_CLAIMED_DOMAIN_ACCOUNT_CREATION
|
||||
}
|
||||
|
||||
PolicyTypeJson.ORGANIZATION_USER_NOTIFICATION -> PolicyType.ORGANIZATION_USER_NOTIFICATION
|
||||
PolicyTypeJson.SEND_CONTROLS -> PolicyType.SEND_CONTROLS
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class RemovePasswordViewModel @Inject constructor(
|
||||
val org = authRepository.userStateFlow.value
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.firstOrNull { it.shouldUseKeyConnector }
|
||||
?.firstOrNull { it.isKeyConnectorEnabled }
|
||||
|
||||
RemovePasswordState(
|
||||
input = "",
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator
|
||||
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
|
||||
import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator
|
||||
import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer
|
||||
import com.bitwarden.ui.platform.composition.LocalClock
|
||||
import com.bitwarden.ui.platform.composition.LocalExitManager
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer
|
||||
@@ -134,11 +135,6 @@ val LocalBiometricsManager: ProvidableCompositionLocal<BiometricsManager> = comp
|
||||
error("CompositionLocal BiometricsManager not present")
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to the clock throughout the app.
|
||||
*/
|
||||
val LocalClock: ProvidableCompositionLocal<Clock> = compositionLocalOf { Clock.systemDefaultZone() }
|
||||
|
||||
/**
|
||||
* Provides access to the Auth Tab launchers throughout the app.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the accessibility disclosure screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data object AccessibilityDisclosureRoute
|
||||
|
||||
/**
|
||||
* Add the accessibility disclosure screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.accessibilityDisclosureDestination(
|
||||
onDismiss: () -> Unit,
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<AccessibilityDisclosureRoute> {
|
||||
AccessibilityDisclosureScreen(onDismiss = onDismiss)
|
||||
// If we are displaying the accessibility disclosure screen, then we can just hide
|
||||
// the splash screen.
|
||||
onSplashScreenRemoved()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the accessibility disclosure screen.
|
||||
*/
|
||||
fun NavController.navigateToAccessibilityDisclosure() {
|
||||
this.navigate(route = AccessibilityDisclosureRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalExitManager
|
||||
import com.bitwarden.ui.platform.manager.exit.ExitManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* Top-level composable for the Accessibility Disclosure screen.
|
||||
*/
|
||||
@Composable
|
||||
fun AccessibilityDisclosureScreen(
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: AccessibilityDisclosureViewModel = hiltViewModel(),
|
||||
exitManager: ExitManager = LocalExitManager.current,
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
is AccessibilityDisclosureEvent.Dismiss -> onDismiss()
|
||||
is AccessibilityDisclosureEvent.CloseApp -> exitManager.exitApplication()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick) }
|
||||
BitwardenScaffold(
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
) {
|
||||
AccessibilityDisclosureContent(
|
||||
onAcceptClick = {
|
||||
viewModel.trySendAction(AccessibilityDisclosureAction.AcceptClicked)
|
||||
},
|
||||
onCloseAppClick = {
|
||||
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessibilityDisclosureContent(
|
||||
onAcceptClick: () -> Unit,
|
||||
onCloseAppClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(state = rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 32.dp))
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_autofill),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(size = 100.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.accessibility_service_disclosure),
|
||||
style = BitwardenTheme.typography.headlineSmall,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.accessibility_disclosure_start_up_text),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.accept),
|
||||
onClick = onAcceptClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.close_app),
|
||||
onClick = onCloseAppClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AccessibilityDisclosureContent_preview() {
|
||||
BitwardenTheme {
|
||||
AccessibilityDisclosureContent(
|
||||
onAcceptClick = {},
|
||||
onCloseAppClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the Accessibility Disclosure screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AccessibilityDisclosureViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : BaseViewModel<
|
||||
AccessibilityDisclosureState,
|
||||
AccessibilityDisclosureEvent,
|
||||
AccessibilityDisclosureAction,
|
||||
>(
|
||||
initialState = AccessibilityDisclosureState,
|
||||
) {
|
||||
override fun handleAction(action: AccessibilityDisclosureAction) {
|
||||
when (action) {
|
||||
AccessibilityDisclosureAction.AcceptClicked -> handleAcceptClicked()
|
||||
AccessibilityDisclosureAction.CloseAppClick -> handleCloseAppClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAcceptClicked() {
|
||||
settingsRepository.accessibilityDisclaimerHasBeenShown()
|
||||
sendEvent(AccessibilityDisclosureEvent.Dismiss)
|
||||
}
|
||||
|
||||
private fun handleCloseAppClick() {
|
||||
sendEvent(AccessibilityDisclosureEvent.CloseApp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Accessibility Disclosure screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data object AccessibilityDisclosureState : Parcelable
|
||||
|
||||
/**
|
||||
* Events for the Accessibility Disclosure screen.
|
||||
*/
|
||||
sealed class AccessibilityDisclosureEvent {
|
||||
/**
|
||||
* Navigate back, dismissing the screen.
|
||||
*/
|
||||
data object Dismiss : AccessibilityDisclosureEvent()
|
||||
|
||||
/**
|
||||
* Closes the app.
|
||||
*/
|
||||
data object CloseApp : AccessibilityDisclosureEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for the Accessibility Disclosure screen.
|
||||
*/
|
||||
sealed class AccessibilityDisclosureAction {
|
||||
/**
|
||||
* User clicked the accept button.
|
||||
*/
|
||||
data object AcceptClicked : AccessibilityDisclosureAction()
|
||||
|
||||
/**
|
||||
* User clicked the close app button.
|
||||
*/
|
||||
data object CloseAppClick : AccessibilityDisclosureAction()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the local network access screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data object LocalNetworkAccessRoute
|
||||
|
||||
/**
|
||||
* Add the local network access screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.localNetworkAccessDestination(
|
||||
onDismiss: () -> Unit,
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<LocalNetworkAccessRoute> {
|
||||
LocalNetworkAccessScreen(onDismiss = onDismiss)
|
||||
// If we are displaying the local network access screen, then we can just hide
|
||||
// the splash screen.
|
||||
onSplashScreenRemoved()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the local network access screen.
|
||||
*/
|
||||
fun NavController.navigateToLocalNetworkAccess() {
|
||||
this.navigate(route = LocalNetworkAccessRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.LifecycleEventEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.manager.util.startAppSettingsActivity
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
|
||||
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers.LocalNetworkAccessHandler
|
||||
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers.rememberLocalNetworkAccessHandler
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private const val LOCAL_NETWORK_PERMISSION: String = Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||
|
||||
/**
|
||||
* Top-level composable for the Local Network Access screen.
|
||||
*/
|
||||
@Composable
|
||||
fun LocalNetworkAccessScreen(
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: LocalNetworkAccessViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LocalNetworkAccessEvent.NavigateBack -> onDismiss()
|
||||
LocalNetworkAccessEvent.NavigateToSettings -> intentManager.startAppSettingsActivity()
|
||||
}
|
||||
}
|
||||
val handler = rememberLocalNetworkAccessHandler(viewModel)
|
||||
LifecycleEventEffect { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
handler.onResumed(permissionsManager.checkPermission(LOCAL_NETWORK_PERMISSION))
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = handler.onCloseClick)
|
||||
|
||||
BitwardenScaffold(
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
) {
|
||||
LocalNetworkAccessContent(
|
||||
permissionsManager = permissionsManager,
|
||||
handler = handler,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun LocalNetworkAccessContent(
|
||||
permissionsManager: PermissionsManager,
|
||||
handler: LocalNetworkAccessHandler,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(value = false) }
|
||||
val localNetworkAccessPermissionLauncher = permissionsManager.getLauncher { isGranted ->
|
||||
if (isGranted) {
|
||||
handler.onCloseClick()
|
||||
} else if (
|
||||
!permissionsManager.shouldShowRequestPermissionRationale(LOCAL_NETWORK_PERMISSION)
|
||||
) {
|
||||
// "shouldShowRequestPermissionRationale" will only be 'true' after you have declined
|
||||
// the first OS prompt but have not seen the second prompt attempt. We do not want
|
||||
// to display the dialog after the first time we were declined, but we do after that.
|
||||
shouldShowPermissionDialog = true
|
||||
}
|
||||
}
|
||||
if (shouldShowPermissionDialog) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.local_network_access_required),
|
||||
message = stringResource(
|
||||
id = BitwardenString
|
||||
.without_this_permission_bitwarden_wont_be_able_to_sync_with_your_server,
|
||||
),
|
||||
confirmButtonText = stringResource(id = BitwardenString.go_to_settings),
|
||||
dismissButtonText = stringResource(id = BitwardenString.no_thanks),
|
||||
onConfirmClick = {
|
||||
shouldShowPermissionDialog = false
|
||||
handler.onSettingsClick()
|
||||
},
|
||||
onDismissClick = { shouldShowPermissionDialog = false },
|
||||
onDismissRequest = { shouldShowPermissionDialog = false },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(state = rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 32.dp))
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_sso_cookie_sync),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(size = 100.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.access_your_local_network),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.bitwarden_needs_local_network_access_to_sync_with_your_server,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.enable_local_network_access),
|
||||
onClick = {
|
||||
localNetworkAccessPermissionLauncher.launch(input = LOCAL_NETWORK_PERMISSION)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.ask_again_later),
|
||||
onClick = handler.onContinueWithoutPermissionClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the Local Network Access screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LocalNetworkAccessViewModel @Inject constructor(
|
||||
private val networkPermissionManager: NetworkPermissionManager,
|
||||
) : BaseViewModel<LocalNetworkAccessState, LocalNetworkAccessEvent, LocalNetworkAccessAction>(
|
||||
initialState = LocalNetworkAccessState,
|
||||
) {
|
||||
override fun handleAction(action: LocalNetworkAccessAction) {
|
||||
when (action) {
|
||||
LocalNetworkAccessAction.CloseClick -> handleCloseClick()
|
||||
LocalNetworkAccessAction.ContinueWithoutPermissionClick -> {
|
||||
handleContinueWithoutPermissionClick()
|
||||
}
|
||||
|
||||
is LocalNetworkAccessAction.Resumed -> handleResumed(action)
|
||||
LocalNetworkAccessAction.SettingsClick -> handleSettingsClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClick() {
|
||||
networkPermissionManager.clearIsLocalNetworkAccessRequired()
|
||||
sendEvent(LocalNetworkAccessEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleContinueWithoutPermissionClick() {
|
||||
networkPermissionManager.clearIsLocalNetworkAccessRequired()
|
||||
sendEvent(LocalNetworkAccessEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleResumed(action: LocalNetworkAccessAction.Resumed) {
|
||||
if (action.hasLocalNetworkAccessPermission) {
|
||||
networkPermissionManager.clearIsLocalNetworkAccessRequired()
|
||||
sendEvent(LocalNetworkAccessEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSettingsClick() {
|
||||
sendEvent(LocalNetworkAccessEvent.NavigateToSettings)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Local Network Access screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data object LocalNetworkAccessState : Parcelable
|
||||
|
||||
/**
|
||||
* Events for the Local Network Access screen.
|
||||
*/
|
||||
sealed class LocalNetworkAccessEvent {
|
||||
/**
|
||||
* Navigate away from this screen.
|
||||
*/
|
||||
data object NavigateBack : LocalNetworkAccessEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the OS settings.
|
||||
*/
|
||||
data object NavigateToSettings : LocalNetworkAccessEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for the Local Network Access screen.
|
||||
*/
|
||||
sealed class LocalNetworkAccessAction {
|
||||
/**
|
||||
* The user has clicked the close button.
|
||||
*/
|
||||
data object CloseClick : LocalNetworkAccessAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the continue without permission button.
|
||||
*/
|
||||
data object ContinueWithoutPermissionClick : LocalNetworkAccessAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the Settings button.
|
||||
*/
|
||||
data object SettingsClick : LocalNetworkAccessAction()
|
||||
|
||||
/**
|
||||
* The screen has resumed.
|
||||
*/
|
||||
data class Resumed(
|
||||
val hasLocalNetworkAccessPermission: Boolean,
|
||||
) : LocalNetworkAccessAction()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessAction
|
||||
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessViewModel
|
||||
|
||||
/**
|
||||
* A class to handle user interactions for the Local Network Access screen.
|
||||
*/
|
||||
data class LocalNetworkAccessHandler(
|
||||
val onCloseClick: () -> Unit,
|
||||
val onContinueWithoutPermissionClick: () -> Unit,
|
||||
val onSettingsClick: () -> Unit,
|
||||
val onResumed: (hasPermission: Boolean) -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Creates an instance of [LocalNetworkAccessHandler] using the provided
|
||||
* [LocalNetworkAccessViewModel].
|
||||
*/
|
||||
fun create(
|
||||
viewModel: LocalNetworkAccessViewModel,
|
||||
): LocalNetworkAccessHandler = LocalNetworkAccessHandler(
|
||||
onCloseClick = { viewModel.trySendAction(LocalNetworkAccessAction.CloseClick) },
|
||||
onContinueWithoutPermissionClick = {
|
||||
viewModel.trySendAction(LocalNetworkAccessAction.ContinueWithoutPermissionClick)
|
||||
},
|
||||
onSettingsClick = { viewModel.trySendAction(LocalNetworkAccessAction.SettingsClick) },
|
||||
onResumed = { viewModel.trySendAction(LocalNetworkAccessAction.Resumed(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create and remember a [LocalNetworkAccessHandler] instance.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLocalNetworkAccessHandler(
|
||||
viewModel: LocalNetworkAccessViewModel,
|
||||
): LocalNetworkAccessHandler = remember(viewModel) {
|
||||
LocalNetworkAccessHandler.create(viewModel)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -20,13 +21,18 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -35,34 +41,43 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.annotatedPluralsResource
|
||||
import com.bitwarden.ui.platform.base.util.annotatedStringResource
|
||||
import com.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.bitwarden.ui.platform.base.util.spanStyleOf
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.badge.BitwardenStatusBadge
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
|
||||
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
|
||||
import com.bitwarden.ui.platform.components.icon.model.IconData
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenPlurals
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.showsFeatureList
|
||||
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
|
||||
|
||||
private const val PLACEHOLDER_TEXT: String = "--"
|
||||
|
||||
/**
|
||||
* The screen for the plan — shows the upgrade flow for free users and the
|
||||
* subscription-management surface for premium users.
|
||||
@@ -90,7 +105,17 @@ fun PlanScreen(
|
||||
)
|
||||
}
|
||||
|
||||
is PlanEvent.LaunchPortal -> intentManager.launchUri(event.url.toUri())
|
||||
is PlanEvent.LaunchPortal -> {
|
||||
intentManager.startAuthTab(
|
||||
uri = event.url.toUri(),
|
||||
authTabData = AuthTabData.CustomScheme(
|
||||
callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL,
|
||||
),
|
||||
launcher = authTabLaunchers.stripePortal,
|
||||
)
|
||||
}
|
||||
|
||||
is PlanEvent.LaunchUri -> intentManager.launchUri(event.url.toUri())
|
||||
PlanEvent.NavigateBack -> onNavigateBack()
|
||||
PlanEvent.NavigateToUpgradedToPremium -> onNavigateToUpgradedToPremium()
|
||||
}
|
||||
@@ -119,13 +144,17 @@ fun PlanScreen(
|
||||
},
|
||||
) {
|
||||
when (val viewState = state.viewState) {
|
||||
is PlanState.ViewState.Free -> {
|
||||
FreeContent(
|
||||
is PlanState.ViewState.Free.Cloud -> {
|
||||
FreeCloudContent(
|
||||
viewState = viewState,
|
||||
handlers = handlers,
|
||||
)
|
||||
}
|
||||
|
||||
is PlanState.ViewState.Free.SelfHosted -> {
|
||||
FreeSelfHostedContent()
|
||||
}
|
||||
|
||||
is PlanState.ViewState.Premium -> {
|
||||
PremiumContent(
|
||||
viewState = viewState,
|
||||
@@ -195,13 +224,14 @@ private fun PlanDialogs(
|
||||
|
||||
is PlanState.DialogState.CancelConfirmation -> {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.cancel_premium),
|
||||
title = stringResource(id = BitwardenString.continue_to_stripe),
|
||||
message = stringResource(
|
||||
id = BitwardenString.cancel_premium_confirmation,
|
||||
id = BitwardenString
|
||||
.youll_be_taken_to_stripe_to_manage_your_subscription_cancellation,
|
||||
dialogState.nextRenewalDate,
|
||||
),
|
||||
confirmButtonText = stringResource(id = BitwardenString.cancel_now),
|
||||
dismissButtonText = stringResource(id = BitwardenString.close),
|
||||
confirmButtonText = stringResource(id = BitwardenString.continue_text),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
onConfirmClick = handlers.onConfirmCancelClick,
|
||||
onDismissClick = handlers.onDismissCancelConfirmation,
|
||||
onDismissRequest = handlers.onDismissCancelConfirmation,
|
||||
@@ -214,7 +244,7 @@ private fun PlanDialogs(
|
||||
message = stringResource(id = BitwardenString.trouble_loading_portal),
|
||||
confirmButtonText = stringResource(id = BitwardenString.try_again),
|
||||
dismissButtonText = stringResource(id = BitwardenString.close),
|
||||
onConfirmClick = handlers.onManagePlanClick,
|
||||
onConfirmClick = handlers.onRetryPortalClick,
|
||||
onDismissClick = handlers.onDismissPortalError,
|
||||
onDismissRequest = handlers.onDismissPortalError,
|
||||
)
|
||||
@@ -247,8 +277,8 @@ private fun PlanDialogs(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FreeContent(
|
||||
viewState: PlanState.ViewState.Free,
|
||||
private fun FreeCloudContent(
|
||||
viewState: PlanState.ViewState.Free.Cloud,
|
||||
handlers: PlanHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -266,34 +296,132 @@ private fun FreeContent(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.upgrade_now),
|
||||
onClick = handlers.onUpgradeNowClick,
|
||||
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth()
|
||||
.testTag("UpgradeNowButton"),
|
||||
)
|
||||
// Hide the Upgrade Now CTA (and its Stripe footer copy) while a Stripe upgrade is
|
||||
// already in flight for the active user. CTAs reappear once the server flips the
|
||||
// user to Premium.
|
||||
if (!viewState.isPremiumUpgradePending) {
|
||||
UpgradeNowCallToAction(
|
||||
onUpgradeNowClick = handlers.onUpgradeNowClick,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradeNowCallToAction(
|
||||
onUpgradeNowClick: () -> Unit,
|
||||
) {
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.upgrade_now),
|
||||
onClick = onUpgradeNowClick,
|
||||
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth()
|
||||
.testTag("UpgradeNowButton"),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.testTag("StripeFooterText"),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Composable
|
||||
private fun FreeSelfHostedContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.stripe_checkout_footer),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
BitwardenInfoCalloutCard(
|
||||
text = stringResource(
|
||||
id = BitwardenString
|
||||
.to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer,
|
||||
),
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_info_circle),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.testTag("StripeFooterText"),
|
||||
.fillMaxWidth()
|
||||
.testTag("SelfHostedManageOnWebVaultCallout"),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PremiumFeaturesCard(
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PremiumFeaturesCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Full,
|
||||
// Override bottom padding to account for custom
|
||||
// `BitwardenContentBlock` vertical padding, below.
|
||||
paddingBottom = 0.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.unlock_premium_features),
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
PremiumFeatureRows()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.PremiumFeatureRows() {
|
||||
BitwardenHorizontalDivider()
|
||||
|
||||
val features = listOf(
|
||||
BitwardenString.built_in_authenticator,
|
||||
BitwardenString.emergency_access,
|
||||
BitwardenString.secure_file_storage,
|
||||
BitwardenString.breach_monitoring,
|
||||
)
|
||||
features.forEachIndexed { index, featureStringRes ->
|
||||
BitwardenContentBlock(
|
||||
data = ContentBlockData(
|
||||
headerText = stringResource(id = featureStringRes),
|
||||
iconVectorResource = BitwardenDrawable.ic_check_mark,
|
||||
),
|
||||
headerTextStyle = BitwardenTheme.typography.titleMedium,
|
||||
showDivider = index != features.lastIndex,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PremiumDetailsCard(
|
||||
rate: String,
|
||||
@@ -380,6 +508,7 @@ private fun PremiumContent(
|
||||
handlers: PlanHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowManagePlanDialog by rememberSaveable { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -395,8 +524,9 @@ private fun PremiumContent(
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.manage_plan),
|
||||
onClick = handlers.onManagePlanClick,
|
||||
onClick = { shouldShowManagePlanDialog = true },
|
||||
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
|
||||
isExternalLink = true,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth()
|
||||
@@ -409,6 +539,7 @@ private fun PremiumContent(
|
||||
label = stringResource(id = BitwardenString.cancel_premium),
|
||||
onClick = handlers.onCancelPremiumClick,
|
||||
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
|
||||
isExternalLink = true,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth()
|
||||
@@ -419,88 +550,135 @@ private fun PremiumContent(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SubscriptionCard(
|
||||
viewState: PlanState.ViewState.Premium,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rowModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Full,
|
||||
// Override bottom padding; the final row owns its own spacing.
|
||||
paddingBottom = 0.dp,
|
||||
if (shouldShowManagePlanDialog) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.continue_to_web_app),
|
||||
message = stringResource(
|
||||
id = BitwardenString.manage_your_subscription_plan_in_the_bitwarden_web_app,
|
||||
),
|
||||
) {
|
||||
SubscriptionHeader(
|
||||
status = viewState.status,
|
||||
descriptionText = viewState.descriptionText,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider()
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.billing_amount),
|
||||
value = viewState.billingAmountText(),
|
||||
testTag = "BillingAmountRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.storage_cost),
|
||||
value = viewState.storageCostText,
|
||||
testTag = "StorageCostRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.discount),
|
||||
value = viewState.discountAmountText,
|
||||
valueColor = if (viewState.discountAmountText == "--") {
|
||||
BitwardenTheme.colorScheme.text.primary
|
||||
} else {
|
||||
BitwardenTheme.colorScheme.statusBadge.success.text
|
||||
confirmButtonText = stringResource(id = BitwardenString.continue_text),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
onConfirmClick = {
|
||||
shouldShowManagePlanDialog = false
|
||||
handlers.onManagePlanClick()
|
||||
},
|
||||
testTag = "DiscountRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.estimated_tax),
|
||||
value = viewState.estimatedTaxText,
|
||||
testTag = "EstimatedTaxRow",
|
||||
modifier = rowModifier,
|
||||
onDismissClick = { shouldShowManagePlanDialog = false },
|
||||
onDismissRequest = { shouldShowManagePlanDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionCard(
|
||||
viewState: PlanState.ViewState.Premium,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Full,
|
||||
// Override bottom padding; the final row (line item or feature) owns its
|
||||
// own spacing.
|
||||
paddingBottom = 0.dp,
|
||||
),
|
||||
) {
|
||||
SubscriptionHeader(
|
||||
status = viewState.status,
|
||||
nextChargeTotalText = viewState.nextChargeTotalText,
|
||||
nextChargeDateText = viewState.nextChargeDateText,
|
||||
cancelAtDateText = viewState.cancelAtDateText,
|
||||
canceledDateText = viewState.canceledDateText,
|
||||
suspensionDateText = viewState.suspensionDateText,
|
||||
gracePeriodDays = viewState.gracePeriodDays,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
if (viewState.status?.showsFeatureList() == true) {
|
||||
PremiumFeatureRows()
|
||||
} else {
|
||||
SubscriptionLineItems(viewState = viewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionLineItems(
|
||||
viewState: PlanState.ViewState.Premium,
|
||||
) {
|
||||
val rowModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
|
||||
BitwardenHorizontalDivider()
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.billing_amount),
|
||||
value = viewState.billingAmountText(),
|
||||
testTag = "BillingAmountRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
|
||||
viewState.storageCostText?.let { storageCostText ->
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.storage_cost),
|
||||
value = storageCostText,
|
||||
testTag = "StorageCostRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
}
|
||||
|
||||
viewState.discountAmountText?.let { discountAmountText ->
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.discount),
|
||||
value = discountAmountText,
|
||||
testTag = "DiscountRow",
|
||||
modifier = rowModifier,
|
||||
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
|
||||
)
|
||||
}
|
||||
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.estimated_tax),
|
||||
value = viewState.estimatedTaxText,
|
||||
testTag = "EstimatedTaxRow",
|
||||
modifier = rowModifier,
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
|
||||
SubscriptionLineItem(
|
||||
label = stringResource(id = BitwardenString.total),
|
||||
value = viewState.totalText(),
|
||||
testTag = "TotalRow",
|
||||
modifier = rowModifier,
|
||||
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionHeader(
|
||||
status: PremiumSubscriptionStatus?,
|
||||
descriptionText: Text?,
|
||||
nextChargeTotalText: String?,
|
||||
nextChargeDateText: String?,
|
||||
cancelAtDateText: String?,
|
||||
canceledDateText: String?,
|
||||
suspensionDateText: String?,
|
||||
gracePeriodDays: Int?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.premium_plan_name),
|
||||
style = BitwardenTheme.typography.titleLarge,
|
||||
style = BitwardenTheme.typography.titleExtraLarge,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
)
|
||||
status?.let {
|
||||
@@ -512,10 +690,20 @@ private fun SubscriptionHeader(
|
||||
}
|
||||
}
|
||||
|
||||
val descriptionText = subscriptionDescriptionText(
|
||||
status = status,
|
||||
nextChargeTotalText = nextChargeTotalText,
|
||||
nextChargeDateText = nextChargeDateText,
|
||||
cancelAtDateText = cancelAtDateText,
|
||||
canceledDateText = canceledDateText,
|
||||
suspensionDateText = suspensionDateText,
|
||||
gracePeriodDays = gracePeriodDays,
|
||||
)
|
||||
|
||||
descriptionText?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = it(),
|
||||
text = it,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
)
|
||||
@@ -523,12 +711,93 @@ private fun SubscriptionHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Composable
|
||||
private fun subscriptionDescriptionText(
|
||||
status: PremiumSubscriptionStatus?,
|
||||
nextChargeTotalText: String?,
|
||||
nextChargeDateText: String?,
|
||||
cancelAtDateText: String?,
|
||||
canceledDateText: String?,
|
||||
suspensionDateText: String?,
|
||||
gracePeriodDays: Int?,
|
||||
): AnnotatedString? {
|
||||
val baseStyle = spanStyleOf(
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textStyle = BitwardenTheme.typography.bodyMedium,
|
||||
)
|
||||
val emphasisStyle = spanStyleOf(
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textStyle = BitwardenTheme.typography.bodyMediumEmphasis,
|
||||
)
|
||||
return when (status) {
|
||||
PremiumSubscriptionStatus.ACTIVE -> annotatedStringResource(
|
||||
id = BitwardenString.premium_next_charge_summary,
|
||||
args = arrayOf(
|
||||
nextChargeTotalText ?: PLACEHOLDER_TEXT,
|
||||
nextChargeDateText ?: PLACEHOLDER_TEXT,
|
||||
),
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.CANCELED -> annotatedStringResource(
|
||||
id = BitwardenString.subscription_canceled_description,
|
||||
args = arrayOf(canceledDateText ?: suspensionDateText ?: PLACEHOLDER_TEXT),
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION -> annotatedStringResource(
|
||||
id = BitwardenString.subscription_pending_cancellation_description,
|
||||
args = arrayOf(cancelAtDateText ?: PLACEHOLDER_TEXT),
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT -> annotatedStringResource(
|
||||
id = BitwardenString.subscription_update_payment_description,
|
||||
args = arrayOf(suspensionDateText ?: PLACEHOLDER_TEXT),
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.PAST_DUE -> {
|
||||
val days = gracePeriodDays ?: 0
|
||||
annotatedPluralsResource(
|
||||
id = BitwardenPlurals.subscription_past_due_description,
|
||||
quantity = days,
|
||||
days.toString(),
|
||||
suspensionDateText ?: PLACEHOLDER_TEXT,
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
}
|
||||
|
||||
PremiumSubscriptionStatus.PAUSED -> AnnotatedString(
|
||||
stringResource(id = BitwardenString.subscription_paused_description),
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.EXPIRED -> annotatedStringResource(
|
||||
id = BitwardenString.subscription_expired_description,
|
||||
args = arrayOf(suspensionDateText ?: PLACEHOLDER_TEXT),
|
||||
style = baseStyle,
|
||||
emphasisHighlightStyle = emphasisStyle,
|
||||
)
|
||||
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionLineItem(
|
||||
label: String,
|
||||
value: String,
|
||||
testTag: String,
|
||||
modifier: Modifier = Modifier,
|
||||
labelStyle: TextStyle = BitwardenTheme.typography.bodyLarge,
|
||||
labelColor: Color = BitwardenTheme.colorScheme.text.secondary,
|
||||
valueStyle: TextStyle = BitwardenTheme.typography.bodyLarge,
|
||||
valueColor: Color = BitwardenTheme.colorScheme.text.primary,
|
||||
) {
|
||||
Row(
|
||||
@@ -540,12 +809,12 @@ private fun SubscriptionLineItem(
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
style = labelStyle,
|
||||
color = labelColor,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
style = valueStyle,
|
||||
color = valueColor,
|
||||
)
|
||||
}
|
||||
@@ -554,14 +823,15 @@ private fun SubscriptionLineItem(
|
||||
@Preview
|
||||
@OmitFromCoverage
|
||||
@Composable
|
||||
private fun PlanScreenFreeAccount_preview() {
|
||||
private fun PlanScreenFreeCloudAccount_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
FreeContent(
|
||||
viewState = PlanState.ViewState.Free(
|
||||
FreeCloudContent(
|
||||
viewState = PlanState.ViewState.Free.Cloud(
|
||||
rate = "$1.67",
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = false,
|
||||
),
|
||||
handlers = PlanHandlers(
|
||||
onBackClick = {},
|
||||
@@ -579,6 +849,7 @@ private fun PlanScreenFreeAccount_preview() {
|
||||
onConfirmCancelClick = {},
|
||||
onDismissCancelConfirmation = {},
|
||||
onDismissPortalError = {},
|
||||
onRetryPortalClick = {},
|
||||
onRetrySubscriptionClick = {},
|
||||
),
|
||||
)
|
||||
@@ -586,6 +857,17 @@ private fun PlanScreenFreeAccount_preview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@OmitFromCoverage
|
||||
@Composable
|
||||
private fun PlanScreenFreeSelfHostedFreeAccount_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
FreeSelfHostedContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@OmitFromCoverage
|
||||
@Composable
|
||||
@@ -595,14 +877,12 @@ private fun PlanScreenPremiumAccount_preview() {
|
||||
PremiumContent(
|
||||
viewState = PlanState.ViewState.Premium(
|
||||
status = PremiumSubscriptionStatus.ACTIVE,
|
||||
descriptionText = BitwardenString.premium_next_charge_summary.asText(
|
||||
"$45.55",
|
||||
"April 2, 2026",
|
||||
),
|
||||
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
|
||||
storageCostText = "$24.00",
|
||||
discountAmountText = "-$2.10",
|
||||
estimatedTaxText = "$3.85",
|
||||
totalText = BitwardenString.billing_rate_per_year.asText("$45.55"),
|
||||
nextChargeTotalText = "$45.55",
|
||||
nextChargeDateText = "April 2, 2026",
|
||||
showCancelButton = true,
|
||||
),
|
||||
@@ -622,6 +902,49 @@ private fun PlanScreenPremiumAccount_preview() {
|
||||
onConfirmCancelClick = {},
|
||||
onDismissCancelConfirmation = {},
|
||||
onDismissPortalError = {},
|
||||
onRetryPortalClick = {},
|
||||
onRetrySubscriptionClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@OmitFromCoverage
|
||||
@Composable
|
||||
private fun PlanScreenPremiumAccountZeroState_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
PremiumContent(
|
||||
viewState = PlanState.ViewState.Premium(
|
||||
status = PremiumSubscriptionStatus.ACTIVE,
|
||||
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
|
||||
storageCostText = null,
|
||||
discountAmountText = null,
|
||||
estimatedTaxText = "$0.00",
|
||||
totalText = BitwardenString.billing_rate_per_year.asText("$19.80"),
|
||||
nextChargeTotalText = "$19.80",
|
||||
nextChargeDateText = "April 2, 2026",
|
||||
showCancelButton = true,
|
||||
),
|
||||
handlers = PlanHandlers(
|
||||
onBackClick = {},
|
||||
onUpgradeNowClick = {},
|
||||
onDismissError = {},
|
||||
onRetryClick = {},
|
||||
onRetryPricingClick = {},
|
||||
onClosePricingErrorClick = {},
|
||||
onCancelWaiting = {},
|
||||
onGoBackClick = {},
|
||||
onSyncClick = {},
|
||||
onContinueClick = {},
|
||||
onManagePlanClick = {},
|
||||
onCancelPremiumClick = {},
|
||||
onConfirmCancelClick = {},
|
||||
onDismissCancelConfirmation = {},
|
||||
onDismissPortalError = {},
|
||||
onRetryPortalClick = {},
|
||||
onRetrySubscriptionClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,29 +6,35 @@ import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.util.toFormattedDateStyle
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenPlurals
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asPluralsText
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
|
||||
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
|
||||
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.billing.util.PremiumCheckoutCallbackResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toBillingAmountText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toDiscountMoneyText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toPresentMoneyText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toRequiredMoneyText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -37,7 +43,6 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.math.BigDecimal
|
||||
import java.text.NumberFormat
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
@@ -58,12 +63,14 @@ const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-checkout-result"
|
||||
* View model for the plan screen, driving the upgrade flow for free users and
|
||||
* the subscription management surface for premium users.
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
|
||||
@HiltViewModel
|
||||
class PlanViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val billingRepository: BillingRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val premiumStateManager: PremiumStateManager,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val clock: Clock,
|
||||
@@ -75,15 +82,21 @@ class PlanViewModel @Inject constructor(
|
||||
.value
|
||||
?.activeAccount
|
||||
?.isPremium == true
|
||||
val showsPremiumView = isPremium ||
|
||||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
|
||||
val isSelfHosted = premiumStateManager.isSelfHosted
|
||||
PlanState(
|
||||
planMode = planMode,
|
||||
viewState = if (isPremium) {
|
||||
PlanState.ViewState.Premium()
|
||||
} else {
|
||||
PlanState.ViewState.Free(
|
||||
viewState = when {
|
||||
showsPremiumView -> PlanState.ViewState.Premium()
|
||||
isSelfHosted -> PlanState.ViewState.Free.SelfHosted
|
||||
else -> PlanState.ViewState.Free.Cloud(
|
||||
rate = PLACEHOLDER_TEXT,
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.value is UpgradeLifecycleState.UpgradePending,
|
||||
)
|
||||
},
|
||||
dialogState = null,
|
||||
@@ -111,7 +124,19 @@ class PlanViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onFreeContent {
|
||||
premiumStateManager
|
||||
.subscriptionStatusStateFlow
|
||||
.map { PlanAction.Internal.SubscriptionStatusUpdateReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onFreeCloudContent {
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.PricingResultReceive(
|
||||
@@ -156,6 +181,7 @@ class PlanViewModel @Inject constructor(
|
||||
is PlanAction.ConfirmCancelClick -> handleConfirmCancelClick()
|
||||
is PlanAction.DismissCancelConfirmation -> handleDismissCancelConfirmation()
|
||||
is PlanAction.DismissPortalError -> handleDismissPortalError()
|
||||
is PlanAction.RetryPortalClick -> handleRetryPortalClick()
|
||||
is PlanAction.RetrySubscriptionClick -> handleRetrySubscriptionClick()
|
||||
is PlanAction.Internal.CheckoutUrlReceive -> handleCheckoutUrlReceive(action)
|
||||
is PlanAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
|
||||
@@ -169,6 +195,14 @@ class PlanViewModel @Inject constructor(
|
||||
is PlanAction.Internal.SubscriptionResultReceive -> {
|
||||
handleSubscriptionResultReceive(action)
|
||||
}
|
||||
|
||||
is PlanAction.Internal.SubscriptionStatusUpdateReceive -> {
|
||||
handleSubscriptionStatusUpdateReceive(action)
|
||||
}
|
||||
|
||||
is PlanAction.Internal.UpgradeLifecycleStateReceive -> {
|
||||
handleUpgradeLifecycleStateReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +263,7 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleGoBackClick() {
|
||||
onFreeContent { freeState ->
|
||||
onFreeCloudContent { freeState ->
|
||||
freeState.checkoutUrl?.let { url ->
|
||||
sendEvent(
|
||||
PlanEvent.LaunchBrowser(
|
||||
@@ -256,7 +290,7 @@ class PlanViewModel @Inject constructor(
|
||||
),
|
||||
),
|
||||
)
|
||||
onFreeContent { freeState ->
|
||||
onFreeCloudContent { freeState ->
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = freeState.copy(
|
||||
@@ -281,7 +315,11 @@ class PlanViewModel @Inject constructor(
|
||||
// region Premium user handlers
|
||||
|
||||
private fun handleManagePlanClick() {
|
||||
launchPortalFetch()
|
||||
val webVaultBaseUrl = environmentRepository
|
||||
.environment
|
||||
.environmentUrlData
|
||||
.baseWebVaultUrlOrDefault
|
||||
sendEvent(PlanEvent.LaunchUri(url = "$webVaultBaseUrl/#/settings/subscription/premium"))
|
||||
}
|
||||
|
||||
private fun handleCancelPremiumClick() {
|
||||
@@ -305,6 +343,10 @@ class PlanViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleRetryPortalClick() {
|
||||
launchPortalFetch()
|
||||
}
|
||||
|
||||
private fun handleDismissPortalError() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
@@ -370,6 +412,31 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionResult.NotFound -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = PlanState.ViewState.Free.Cloud(
|
||||
rate = PLACEHOLDER_TEXT,
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.value is UpgradeLifecycleState.UpgradePending,
|
||||
),
|
||||
dialogState = PlanState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.PricingResultReceive(
|
||||
result = billingRepository.getPremiumPlanPricing(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SubscriptionResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
@@ -385,6 +452,48 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpgradeLifecycleStateReceive(
|
||||
action: PlanAction.Internal.UpgradeLifecycleStateReceive,
|
||||
) {
|
||||
val isPending = action.state is UpgradeLifecycleState.UpgradePending
|
||||
onFreeCloudContent { freeState ->
|
||||
if (freeState.isPremiumUpgradePending == isPending) return@onFreeCloudContent
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = freeState.copy(
|
||||
isPremiumUpgradePending = isPending,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSubscriptionStatusUpdateReceive(
|
||||
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
|
||||
) {
|
||||
val status = (action.state as? SubscriptionStatusState.Available)?.status
|
||||
?: return
|
||||
if (!status.isPremiumViewEligible()) return
|
||||
onFreeCloudContent { freeState ->
|
||||
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = PlanState.ViewState.Premium(),
|
||||
dialogState = PlanState.DialogState.Loading(
|
||||
message = BitwardenString.loading_subscription.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.SubscriptionResultReceive(
|
||||
result = billingRepository.getSubscription(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion Premium user handlers
|
||||
|
||||
// region Shared handlers
|
||||
@@ -392,8 +501,8 @@ class PlanViewModel @Inject constructor(
|
||||
private fun handleUserStateUpdateReceive(
|
||||
action: PlanAction.Internal.UserStateUpdateReceive,
|
||||
) {
|
||||
onFreeContent { freeState ->
|
||||
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
|
||||
onFreeCloudContent { freeState ->
|
||||
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
|
||||
|
||||
val isPremium = action.userState?.activeAccount?.isPremium == true
|
||||
if (isPremium) {
|
||||
@@ -405,12 +514,41 @@ class PlanViewModel @Inject constructor(
|
||||
private fun handleSpecialCircumstanceReceive(
|
||||
action: PlanAction.Internal.SpecialCircumstanceReceive,
|
||||
) {
|
||||
val checkoutResult = action.specialCircumstance
|
||||
as? SpecialCircumstance.PremiumCheckout ?: return
|
||||
when (val circumstance = action.specialCircumstance) {
|
||||
is SpecialCircumstance.PremiumCheckout -> {
|
||||
handlePremiumCheckoutCircumstance(circumstance)
|
||||
}
|
||||
|
||||
SpecialCircumstance.StripePortal -> handleStripePortalCircumstance()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStripePortalCircumstance() {
|
||||
specialCircumstanceManager.specialCircumstance = null
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = PlanState.DialogState.Loading(
|
||||
message = BitwardenString.loading_subscription.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.SubscriptionResultReceive(
|
||||
result = billingRepository.getSubscription(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePremiumCheckoutCircumstance(
|
||||
checkoutResult: SpecialCircumstance.PremiumCheckout,
|
||||
) {
|
||||
specialCircumstanceManager.specialCircumstance = null
|
||||
|
||||
if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) {
|
||||
onFreeContent { freeState ->
|
||||
onFreeCloudContent { freeState ->
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = freeState.copy(
|
||||
@@ -431,7 +569,7 @@ class PlanViewModel @Inject constructor(
|
||||
if (isPremium) {
|
||||
onPremiumUpgradeSuccess()
|
||||
} else {
|
||||
onFreeContent { freeState ->
|
||||
onFreeCloudContent { freeState ->
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = freeState.copy(
|
||||
@@ -455,17 +593,22 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleSyncCompleteReceive() {
|
||||
onFreeContent { freeState ->
|
||||
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
|
||||
onFreeCloudContent { freeState ->
|
||||
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
|
||||
|
||||
val isPremium = authRepository
|
||||
val activeAccount = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.isPremium == true
|
||||
val isPremium = activeAccount?.isPremium == true
|
||||
if (isPremium) {
|
||||
onPremiumUpgradeSuccess()
|
||||
} else {
|
||||
// Persist the pending-upgrade signal so the Vault banner and the Plan-screen
|
||||
// Upgrade Now CTA can suppress themselves while the server catches up.
|
||||
activeAccount?.userId?.let { userId ->
|
||||
premiumStateManager.markPremiumUpgradePending(userId = userId)
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = PlanState.DialogState.PendingUpgrade,
|
||||
@@ -476,7 +619,7 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun onPremiumUpgradeSuccess() {
|
||||
onFreeContent {
|
||||
onFreeCloudContent {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = PlanState.ViewState.Premium(),
|
||||
@@ -495,7 +638,7 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
// The Upgraded to Premium route uses `launchSingleTop = true` so a duplicate event is a
|
||||
// no-op for the user. The event itself is harmless to re-emit; the state mutation above
|
||||
// is what's guarded by `onFreeContent`.
|
||||
// is what's guarded by `onFreeCloudContent`.
|
||||
sendEvent(PlanEvent.NavigateToUpgradedToPremium)
|
||||
}
|
||||
|
||||
@@ -508,8 +651,10 @@ class PlanViewModel @Inject constructor(
|
||||
.format(result.annualPrice / MONTHS_PER_YEAR)
|
||||
mutableStateFlow.update { currentState ->
|
||||
val updatedViewState = when (val vs = currentState.viewState) {
|
||||
is PlanState.ViewState.Free -> vs.copy(rate = formattedRate)
|
||||
is PlanState.ViewState.Premium -> vs
|
||||
is PlanState.ViewState.Free.Cloud -> vs.copy(rate = formattedRate)
|
||||
is PlanState.ViewState.Free.SelfHosted,
|
||||
is PlanState.ViewState.Premium,
|
||||
-> vs
|
||||
}
|
||||
currentState.copy(
|
||||
viewState = updatedViewState,
|
||||
@@ -549,10 +694,10 @@ class PlanViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun onFreeContent(
|
||||
block: (PlanState.ViewState.Free) -> Unit,
|
||||
private inline fun onFreeCloudContent(
|
||||
block: (PlanState.ViewState.Free.Cloud) -> Unit,
|
||||
) {
|
||||
(state.viewState as? PlanState.ViewState.Free)?.let(block)
|
||||
(state.viewState as? PlanState.ViewState.Free.Cloud)?.let(block)
|
||||
}
|
||||
|
||||
private inline fun onPremiumContent(
|
||||
@@ -564,77 +709,27 @@ class PlanViewModel @Inject constructor(
|
||||
private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Premium {
|
||||
val formattedTotal = currencyFormatter.format(nextChargeTotal)
|
||||
val formattedDate = nextCharge?.toLocalizedDate()
|
||||
val formattedCancelAt = cancelAt?.toLocalizedDate()
|
||||
val formattedCanceled = canceledDate?.toLocalizedDate()
|
||||
val formattedSuspension = suspensionDate?.toLocalizedDate()
|
||||
|
||||
return PlanState.ViewState.Premium(
|
||||
status = status,
|
||||
descriptionText = toDescriptionText(
|
||||
formattedTotal = formattedTotal,
|
||||
nextChargeDate = formattedDate,
|
||||
canceledDate = formattedCanceled,
|
||||
suspensionDate = formattedSuspension,
|
||||
),
|
||||
billingAmountText = seatsCost.toBillingAmountText(cadence),
|
||||
storageCostText = storageCost.toMoneyText(),
|
||||
discountAmountText = discountAmount.toMoneyText(negative = true),
|
||||
estimatedTaxText = estimatedTax.toMoneyText(),
|
||||
billingAmountText = seatsCost.toBillingAmountText(cadence, currencyFormatter),
|
||||
storageCostText = storageCost.toPresentMoneyText(currencyFormatter),
|
||||
discountAmountText = discountAmount.toDiscountMoneyText(currencyFormatter),
|
||||
estimatedTaxText = estimatedTax.toRequiredMoneyText(currencyFormatter),
|
||||
totalText = nextChargeTotal.toBillingAmountText(cadence, currencyFormatter),
|
||||
nextChargeTotalText = formattedTotal,
|
||||
nextChargeDateText = formattedDate,
|
||||
showCancelButton = status != PremiumSubscriptionStatus.CANCELED,
|
||||
cancelAtDateText = formattedCancelAt,
|
||||
canceledDateText = formattedCanceled,
|
||||
suspensionDateText = formattedSuspension,
|
||||
gracePeriodDays = gracePeriodDays,
|
||||
showCancelButton = status.canBeCanceled(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text {
|
||||
if (this.signum() == 0) return PLACEHOLDER_TEXT.asText()
|
||||
val formatted = currencyFormatter.format(this)
|
||||
val cadenceRes = when (cadence) {
|
||||
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
|
||||
PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month
|
||||
}
|
||||
return cadenceRes.asText(formatted)
|
||||
}
|
||||
|
||||
private fun BigDecimal?.toMoneyText(negative: Boolean = false): String =
|
||||
when {
|
||||
this == null || this.signum() == 0 -> PLACEHOLDER_TEXT
|
||||
negative -> "-${currencyFormatter.format(this)}"
|
||||
else -> currencyFormatter.format(this)
|
||||
}
|
||||
|
||||
private fun SubscriptionInfo.toDescriptionText(
|
||||
formattedTotal: String,
|
||||
nextChargeDate: String?,
|
||||
canceledDate: String?,
|
||||
suspensionDate: String?,
|
||||
): Text =
|
||||
when (status) {
|
||||
PremiumSubscriptionStatus.ACTIVE ->
|
||||
BitwardenString.premium_next_charge_summary.asText(
|
||||
formattedTotal,
|
||||
nextChargeDate ?: PLACEHOLDER_TEXT,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.CANCELED ->
|
||||
BitwardenString.subscription_canceled_description.asText(
|
||||
canceledDate ?: PLACEHOLDER_TEXT,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.OVERDUE_PAYMENT ->
|
||||
BitwardenString.subscription_overdue_description.asText(
|
||||
suspensionDate ?: PLACEHOLDER_TEXT,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.PAST_DUE ->
|
||||
BitwardenPlurals.subscription_past_due_description.asPluralsText(
|
||||
gracePeriodDays ?: 0,
|
||||
gracePeriodDays ?: 0,
|
||||
suspensionDate ?: PLACEHOLDER_TEXT,
|
||||
)
|
||||
|
||||
PremiumSubscriptionStatus.PAUSED ->
|
||||
BitwardenString.subscription_paused_description.asText()
|
||||
}
|
||||
|
||||
private fun Instant.toLocalizedDate(): String =
|
||||
toFormattedDateStyle(
|
||||
dateStyle = FormatStyle.LONG,
|
||||
@@ -703,32 +798,63 @@ data class PlanState(
|
||||
sealed class ViewState : Parcelable {
|
||||
|
||||
/**
|
||||
* Free user view — shows upgrade pricing and feature list.
|
||||
* Free user view — shows the upgrade flow for cloud accounts or a
|
||||
* "manage on web vault" info card for self-hosted accounts.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Free(
|
||||
val rate: String,
|
||||
val checkoutUrl: String?,
|
||||
val isAwaitingPremiumStatus: Boolean,
|
||||
) : ViewState()
|
||||
sealed class Free : ViewState() {
|
||||
|
||||
/**
|
||||
* Free user on a cloud-hosted environment — shows upgrade pricing
|
||||
* and feature list.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Cloud(
|
||||
val rate: String,
|
||||
val checkoutUrl: String?,
|
||||
val isAwaitingPremiumStatus: Boolean,
|
||||
val isPremiumUpgradePending: Boolean,
|
||||
) : Free()
|
||||
|
||||
/**
|
||||
* Free user on a self-hosted environment — Stripe checkout is
|
||||
* unavailable, so the screen redirects the user to manage their
|
||||
* subscription on the web vault.
|
||||
*/
|
||||
@Parcelize
|
||||
data object SelfHosted : Free()
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium user view — shows subscription details and management options.
|
||||
*
|
||||
* Line-item text fields are always populated: they default to the
|
||||
* `"--"` placeholder during the initial load and for any value that
|
||||
* resolves to null or `0.00` (e.g. no additional storage, no discount,
|
||||
* no tax).
|
||||
* Line-item text fields follow two visibility contracts that mirror the
|
||||
* canonical Web subscription card:
|
||||
*
|
||||
* - **Required** ([billingAmountText], [estimatedTaxText], [totalText]):
|
||||
* the row is always rendered. A zero amount is formatted as `$0.00`
|
||||
* rather than hidden. Defaults are sensible empty values used only
|
||||
* during the initial load — the `DialogState.Loading` overlay covers
|
||||
* the screen during the fetch, so these defaults are never surfaced
|
||||
* to the user.
|
||||
* - **Optional** ([storageCostText], [discountAmountText]): a `null`
|
||||
* value signals the screen to omit the row entirely (along with its
|
||||
* leading divider). When non-null, the value is fully formatted by
|
||||
* the view model — the screen renders it verbatim.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Premium(
|
||||
val status: PremiumSubscriptionStatus? = null,
|
||||
val descriptionText: Text? = null,
|
||||
val billingAmountText: Text = PLACEHOLDER_TEXT.asText(),
|
||||
val storageCostText: String = PLACEHOLDER_TEXT,
|
||||
val discountAmountText: String = PLACEHOLDER_TEXT,
|
||||
val estimatedTaxText: String = PLACEHOLDER_TEXT,
|
||||
val billingAmountText: Text = "".asText(),
|
||||
val storageCostText: String? = null,
|
||||
val discountAmountText: String? = null,
|
||||
val estimatedTaxText: String = "$0.00",
|
||||
val totalText: Text = "".asText(),
|
||||
val nextChargeTotalText: String? = null,
|
||||
val nextChargeDateText: String? = null,
|
||||
val cancelAtDateText: String? = null,
|
||||
val canceledDateText: String? = null,
|
||||
val suspensionDateText: String? = null,
|
||||
val gracePeriodDays: Int? = null,
|
||||
val showCancelButton: Boolean = false,
|
||||
) : ViewState()
|
||||
}
|
||||
@@ -826,6 +952,13 @@ sealed class PlanEvent {
|
||||
val url: String,
|
||||
) : PlanEvent()
|
||||
|
||||
/**
|
||||
* Launch the user's browser with the given web vault [url].
|
||||
*/
|
||||
data class LaunchUri(
|
||||
val url: String,
|
||||
) : PlanEvent()
|
||||
|
||||
/**
|
||||
* Navigate back to the previous screen.
|
||||
*/
|
||||
@@ -925,6 +1058,11 @@ sealed class PlanAction {
|
||||
*/
|
||||
data object DismissPortalError : PlanAction()
|
||||
|
||||
/**
|
||||
* The user clicked retry on the portal error dialog.
|
||||
*/
|
||||
data object RetryPortalClick : PlanAction()
|
||||
|
||||
/**
|
||||
* The user clicked retry on the subscription error dialog.
|
||||
*/
|
||||
@@ -985,5 +1123,63 @@ sealed class PlanAction {
|
||||
data class SubscriptionResultReceive(
|
||||
val result: SubscriptionResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The shared subscription status state for the active user has updated.
|
||||
*/
|
||||
data class SubscriptionStatusUpdateReceive(
|
||||
val state: SubscriptionStatusState,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The shared [UpgradeLifecycleState] for the active user has updated.
|
||||
*/
|
||||
data class UpgradeLifecycleStateReceive(
|
||||
val state: UpgradeLifecycleState,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when this status corresponds to a subscription that the user can still
|
||||
* cancel through the Stripe portal — i.e., a live subscription. Terminal states (canceled,
|
||||
* expired, pending cancellation) and states whose primary action is recovering payment
|
||||
* (update payment) do not present a cancel action.
|
||||
*/
|
||||
private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> false
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
-> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when this status should route the Plan screen to the Premium view even
|
||||
* if `Account.isPremium=false`. Trouble states (canceled, past due, paused, update payment)
|
||||
* carry enough context to render a status badge and Manage/Resubscribe affordances, which
|
||||
* the Free view does not surface.
|
||||
*/
|
||||
private fun PremiumSubscriptionStatus.isPremiumViewEligible(): Boolean = when (this) {
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> true
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the current [SubscriptionStatusState] indicates that the Plan screen
|
||||
* should render the Premium view, even if the user account's `isPremium` flag is `false`.
|
||||
*/
|
||||
private fun SubscriptionStatusState.isPremiumViewEligible(): Boolean =
|
||||
this is SubscriptionStatusState.Available && this.status.isPremiumViewEligible()
|
||||
|
||||
@@ -24,6 +24,7 @@ data class PlanHandlers(
|
||||
val onConfirmCancelClick: () -> Unit,
|
||||
val onDismissCancelConfirmation: () -> Unit,
|
||||
val onDismissPortalError: () -> Unit,
|
||||
val onRetryPortalClick: () -> Unit,
|
||||
val onRetrySubscriptionClick: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
@@ -58,6 +59,9 @@ data class PlanHandlers(
|
||||
onDismissPortalError = {
|
||||
viewModel.trySendAction(PlanAction.DismissPortalError)
|
||||
},
|
||||
onRetryPortalClick = {
|
||||
viewModel.trySendAction(PlanAction.RetryPortalClick)
|
||||
},
|
||||
onRetrySubscriptionClick = {
|
||||
viewModel.trySendAction(PlanAction.RetrySubscriptionClick)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util
|
||||
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
|
||||
import java.math.BigDecimal
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
* Formats this amount as a cadence-qualified billing rate (e.g. "$10.00 per year"), using
|
||||
* [currencyFormatter] for the locale-aware currency value.
|
||||
*/
|
||||
fun BigDecimal.toBillingAmountText(
|
||||
cadence: PlanCadence,
|
||||
currencyFormatter: NumberFormat,
|
||||
): Text {
|
||||
val formatted = currencyFormatter.format(this)
|
||||
val cadenceRes = when (cadence) {
|
||||
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
|
||||
PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month
|
||||
}
|
||||
return cadenceRes.asText(formatted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats this amount for an always-rendered line item. Null is coerced to zero so the row still
|
||||
* shows the locale-formatted `$0.00`, as the Estimated Tax and Total rows always render.
|
||||
*/
|
||||
fun BigDecimal?.toRequiredMoneyText(currencyFormatter: NumberFormat): String =
|
||||
currencyFormatter.format(this ?: BigDecimal.ZERO)
|
||||
|
||||
/**
|
||||
* Formats this amount for a render-when-present line item (Storage), rendering `$0.00` for a
|
||||
* free line and returning `null` only when the amount is `null`.
|
||||
*/
|
||||
fun BigDecimal?.toPresentMoneyText(currencyFormatter: NumberFormat): String? =
|
||||
this?.let { currencyFormatter.format(it) }
|
||||
|
||||
/**
|
||||
* Formats this amount as a negative money string for the Discount line item (e.g. "-$5.00"),
|
||||
* returning `null` when the amount is `null` or non-positive so the row is omitted when there is
|
||||
* no discount.
|
||||
*/
|
||||
fun BigDecimal?.toDiscountMoneyText(currencyFormatter: NumberFormat): String? =
|
||||
this
|
||||
?.takeIf { it.signum() > 0 }
|
||||
?.let { "\u2212${currencyFormatter.format(it)}" }
|
||||
@@ -14,12 +14,32 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
|
||||
fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
|
||||
PremiumSubscriptionStatus.ACTIVE -> BitwardenString.subscription_status_active
|
||||
PremiumSubscriptionStatus.CANCELED -> BitwardenString.subscription_status_canceled
|
||||
PremiumSubscriptionStatus.OVERDUE_PAYMENT -> {
|
||||
BitwardenString.subscription_status_overdue_payment
|
||||
PremiumSubscriptionStatus.EXPIRED -> BitwardenString.subscription_status_expired
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION -> {
|
||||
BitwardenString.subscription_status_pending_cancellation
|
||||
}
|
||||
|
||||
PremiumSubscriptionStatus.PAST_DUE -> BitwardenString.subscription_status_past_due
|
||||
PremiumSubscriptionStatus.PAUSED -> BitwardenString.subscription_status_paused
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT -> BitwardenString.subscription_status_update_payment
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the Premium plan card should replace its billing line items with the
|
||||
* premium feature list. Reserved for terminal states where line items carry no actionable
|
||||
* information and the user's path forward is to resubscribe.
|
||||
*/
|
||||
fun PremiumSubscriptionStatus.showsFeatureList(): Boolean = when (this) {
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
-> true
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,11 +50,13 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
|
||||
fun PremiumSubscriptionStatus.badgeColors(): BitwardenColorScheme.StatusBadgeVariantColors =
|
||||
when (this) {
|
||||
PremiumSubscriptionStatus.ACTIVE -> BitwardenTheme.colorScheme.statusBadge.success
|
||||
PremiumSubscriptionStatus.CANCELED -> BitwardenTheme.colorScheme.statusBadge.error
|
||||
PremiumSubscriptionStatus.OVERDUE_PAYMENT,
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
-> BitwardenTheme.colorScheme.statusBadge.error
|
||||
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
-> {
|
||||
BitwardenTheme.colorScheme.statusBadge.warning
|
||||
}
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> BitwardenTheme.colorScheme.statusBadge.warning
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user