mirror of
https://github.com/bitwarden/android.git
synced 2026-06-20 04:29:37 -05:00
Compare commits
74 Commits
PM-37255/c
...
agalles/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef60f4a5f | ||
|
|
4c915e1c27 | ||
|
|
59b9cbfa31 | ||
|
|
5b8dd94b07 | ||
|
|
843121154d | ||
|
|
9f0b608253 | ||
|
|
7d062b9617 | ||
|
|
fe8bf57360 | ||
|
|
afab585d1f | ||
|
|
74f7dea574 | ||
|
|
fbd579b973 | ||
|
|
ce09418462 | ||
|
|
bf0770657f | ||
|
|
2432665327 | ||
|
|
cc9b7d3edf | ||
|
|
453ade7cca | ||
|
|
2355e89248 | ||
|
|
38045c9464 | ||
|
|
0f13d97cb1 | ||
|
|
2c7eef2b8c | ||
|
|
c431fcba9b | ||
|
|
2706c89302 | ||
|
|
af665cbe82 | ||
|
|
2572757cda | ||
|
|
fbed80c904 | ||
|
|
6930d077aa | ||
|
|
fbbbe578e5 | ||
|
|
378f77a55b | ||
|
|
b3cd24c6e9 | ||
|
|
b85e30fba2 | ||
|
|
2022507cc0 | ||
|
|
c0caaf9ce9 | ||
|
|
fdeafd7388 | ||
|
|
9b85e028f7 | ||
|
|
0cd9c99fcf | ||
|
|
86cf21602c | ||
|
|
c45ae58b0e | ||
|
|
d44212d9bd | ||
|
|
1c90dc242f | ||
|
|
22ad8ec78f | ||
|
|
b5a6ab0ac0 | ||
|
|
ecbceb8baf | ||
|
|
bd45d5f56a | ||
|
|
051b8b53d1 | ||
|
|
9c8b4891a1 | ||
|
|
5fd32ab391 | ||
|
|
57a14f2785 | ||
|
|
c18dd58c41 | ||
|
|
227359bc26 | ||
|
|
fa219f6963 | ||
|
|
a3bcff9463 | ||
|
|
aca9949874 | ||
|
|
217bfc1097 | ||
|
|
a94978c8e2 | ||
|
|
0a920d5800 | ||
|
|
09f0f5b9bf | ||
|
|
b57fb9c437 | ||
|
|
124ce37bc3 | ||
|
|
e7e2c26bef | ||
|
|
c89a52e5d2 | ||
|
|
fb955e903f | ||
|
|
230c8f769d | ||
|
|
8661dfaf2f | ||
|
|
a872db128b | ||
|
|
40604d0ec0 | ||
|
|
0f4b3fb9f0 | ||
|
|
a0edef99e6 | ||
|
|
cc6fcecc5b | ||
|
|
58408bcd77 | ||
|
|
3732672ab4 | ||
|
|
b53d3fbd29 | ||
|
|
f0f1f91c62 | ||
|
|
ecc47005fb | ||
|
|
3fc5965a05 |
2
.github/workflows/crowdin-pull.yml
vendored
2
.github/workflows/crowdin-pull.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for creating and pushing a new branch
|
||||
permission-pull-requests: write # for creating pull request
|
||||
|
||||
280
.github/workflows/github-release.yml
vendored
280
.github/workflows/github-release.yml
vendored
@@ -16,279 +16,17 @@ env:
|
||||
ARTIFACTS_PATH: artifacts
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
jobs:
|
||||
deploy:
|
||||
name: Trigger publish via deploy repo
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Trigger publish
|
||||
uses: bitwarden/gh-actions/trigger-actions@main
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Log inputs to job summary
|
||||
uses: ./.github/actions/log-inputs
|
||||
with:
|
||||
inputs: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Get branch from workflow run
|
||||
id: get_release_branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
run: |
|
||||
workflow_data=$(gh run view "$ARTIFACT_RUN_ID" --json headBranch,workflowName)
|
||||
release_branch=$(echo "$workflow_data" | jq -r .headBranch)
|
||||
workflow_name=$(echo "$workflow_data" | jq -r .workflowName)
|
||||
|
||||
# branch protection check
|
||||
if [[ "$release_branch" != "main" && ! "$release_branch" =~ ^release/ ]]; then
|
||||
echo "::error::Branch '$release_branch' is not 'main' or a release branch starting with 'release/'. Releases must be created from protected branches."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔖 Release branch: $release_branch"
|
||||
echo "🔖 Workflow name: $workflow_name"
|
||||
echo "release_branch=$release_branch" >> "$GITHUB_OUTPUT"
|
||||
echo "workflow_name=$workflow_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
case "$workflow_name" in
|
||||
*"Password Manager"* | "Build")
|
||||
app_name="Password Manager"
|
||||
app_name_suffix="bwpm"
|
||||
;;
|
||||
*"Authenticator"*)
|
||||
app_name="Authenticator"
|
||||
app_name_suffix="bwa"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown workflow name: $workflow_name"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "🔖 App name: $app_name"
|
||||
echo "🔖 App name suffix: $app_name_suffix"
|
||||
echo "app_name=$app_name" >> "$GITHUB_OUTPUT"
|
||||
echo "app_name_suffix=$app_name_suffix" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get version info from run logs and set release tag name
|
||||
id: get_release_info
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_APP_NAME_SUFFIX: ${{ steps.get_release_branch.outputs.app_name_suffix }}
|
||||
run: |
|
||||
workflow_log=$(gh run view "$ARTIFACT_RUN_ID" --log)
|
||||
|
||||
version_number_with_trailing_dot=$(grep -m 1 "Setting version code to" <<< "$workflow_log" | sed 's/.*Setting version code to //')
|
||||
version_number=${version_number_with_trailing_dot%.} # remove trailing dot
|
||||
|
||||
version_name_with_trailing_dot=$(grep -m 1 "Setting version name to" <<< "$workflow_log" | sed 's/.*Setting version name to //')
|
||||
version_name=${version_name_with_trailing_dot%.} # remove trailing dot
|
||||
|
||||
if [[ -z "$version_name" ]]; then
|
||||
echo "::warning::Version name not found. Using default value - 0.0.0"
|
||||
version_name="0.0.0"
|
||||
else
|
||||
echo "✅ Found version name: $version_name"
|
||||
fi
|
||||
|
||||
if [[ -z "$version_number" ]]; then
|
||||
echo "::warning::Version number not found. Using default value - 0"
|
||||
version_number="0"
|
||||
else
|
||||
echo "✅ Found version number: $version_number"
|
||||
fi
|
||||
|
||||
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
tag_name="v$version_name-$_APP_NAME_SUFFIX" # e.g. v2025.6.0-bwpm
|
||||
echo "🔖 New tag name: $tag_name"
|
||||
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
|
||||
echo "🔖 Last release tag: $last_release_tag"
|
||||
echo "last_release_tag=$last_release_tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
run: |
|
||||
gh run download "$ARTIFACT_RUN_ID" -D "$ARTIFACTS_PATH"
|
||||
file_count=$(find "$ARTIFACTS_PATH" -type f | wc -l)
|
||||
echo "Downloaded $file_count file(s)."
|
||||
if [ "$file_count" -gt 0 ]; then
|
||||
echo "Downloaded files:"
|
||||
find "$ARTIFACTS_PATH" -type f
|
||||
fi
|
||||
|
||||
# Files that won't be included in any release
|
||||
files_to_remove=(
|
||||
"com.x8bit.bitwarden.aab"
|
||||
"com.x8bit.bitwarden.aab-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.beta.apk"
|
||||
"com.x8bit.bitwarden.beta.apk-sha256.txt"
|
||||
"com.x8bit.bitwarden.beta.aab"
|
||||
"com.x8bit.bitwarden.beta.aab-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.beta-fdroid.apk"
|
||||
"com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.dev.apk"
|
||||
"com.x8bit.bitwarden.dev.apk-sha256.txt"
|
||||
|
||||
"com.bitwarden.authenticator.aab"
|
||||
"authenticator-android-aab-sha256.txt"
|
||||
)
|
||||
|
||||
for file in "${files_to_remove[@]}"; do
|
||||
find "$ARTIFACTS_PATH" -name "$file" -type f -delete
|
||||
done
|
||||
echo "🔖 Removed internal artifacts."
|
||||
echo ""
|
||||
echo "🔖 Files to be included in the release:"
|
||||
find "$ARTIFACTS_PATH" -type f
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN,JIRA-CLOUD-ID"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Get product release notes
|
||||
id: get_release_notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
|
||||
_JIRA_CLOUD_ID: ${{ steps.get-kv-secrets.outputs.JIRA-CLOUD-ID }}
|
||||
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
|
||||
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
|
||||
run: |
|
||||
echo "Getting product release notes..."
|
||||
# capture output and exit code so this step continues even if we can't retrieve release notes.
|
||||
script_exit_code=0
|
||||
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_CLOUD_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
|
||||
echo "--------------------------------"
|
||||
|
||||
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
|
||||
echo "Script Output: $product_release_notes"
|
||||
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
|
||||
product_release_notes="<insert product release notes here>"
|
||||
else
|
||||
echo "✅ Product release notes:"
|
||||
echo "$product_release_notes"
|
||||
fi
|
||||
|
||||
echo "$product_release_notes" > product_release_notes.txt
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_APP_NAME: ${{ steps.get_release_branch.outputs.app_name }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
|
||||
_TARGET_COMMIT: ${{ steps.get_release_branch.outputs.release_branch }}
|
||||
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
|
||||
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
|
||||
run: |
|
||||
is_latest_release=false
|
||||
if [[ "$_APP_NAME" == "Password Manager" ]]; then
|
||||
is_latest_release=true
|
||||
fi
|
||||
|
||||
echo "⌛️ Creating release for $_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER) on $_TARGET_COMMIT"
|
||||
release_url=$(gh release create "$_TAG_NAME" \
|
||||
--title "$_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER)" \
|
||||
--target "$_TARGET_COMMIT" \
|
||||
--generate-notes \
|
||||
--notes-start-tag "$_LAST_RELEASE_TAG" \
|
||||
--latest=$is_latest_release \
|
||||
--draft \
|
||||
"$ARTIFACTS_PATH/*/*")
|
||||
|
||||
# Extract release tag from URL
|
||||
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
|
||||
echo "release_id_from_url=$release_id_from_url" >> "$GITHUB_OUTPUT"
|
||||
echo "url=$release_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ Release created: $release_url"
|
||||
echo "🔖 Release ID from URL: $release_id_from_url"
|
||||
|
||||
- name: Update Release Description
|
||||
id: update_release_description
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_RELEASE_ID: ${{ steps.create_release.outputs.release_id_from_url }}
|
||||
run: |
|
||||
echo "Getting current release body. Release ID: $_RELEASE_ID"
|
||||
current_body=$(gh release view "$_RELEASE_ID" --json body --jq .body)
|
||||
|
||||
product_release_notes=$(cat product_release_notes.txt)
|
||||
|
||||
# Update release description with product release notes and builds source
|
||||
updated_body="# Overview
|
||||
${product_release_notes}
|
||||
|
||||
${current_body}
|
||||
**Builds Source:** https://github.com/${{ github.repository }}/actions/runs/$ARTIFACT_RUN_ID"
|
||||
|
||||
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
|
||||
|
||||
# draft release links change after editing
|
||||
echo "release_url=$new_release_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add Release Summary
|
||||
env:
|
||||
_RELEASE_TAG: ${{ steps.get_release_info.outputs.tag_name }}
|
||||
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
|
||||
_RELEASE_BRANCH: ${{ steps.get_release_branch.outputs.release_branch }}
|
||||
_RELEASE_URL: ${{ steps.update_release_description.outputs.release_url }}
|
||||
run: |
|
||||
{
|
||||
echo "# :fish_cake: Release ready at:"
|
||||
echo "$_RELEASE_URL"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "$_VERSION_NAME" == "0.0.0" || "$_VERSION_NUMBER" == "0" ]]; then
|
||||
{
|
||||
echo "> [!CAUTION]"
|
||||
echo "> Version name or number wasn't previously found and a default value was used. You'll need to manually update the release Title, Tag and Description, specifically, the \"Full Changelog\" link."
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
{
|
||||
echo ":clipboard: Confirm that the defined GitHub Release options are correct:"
|
||||
echo " * :bookmark: New tag name: \`$_RELEASE_TAG\`"
|
||||
echo " * :palm_tree: Target branch: \`$_RELEASE_BRANCH\`"
|
||||
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
|
||||
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
|
||||
echo "> [!NOTE]"
|
||||
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
task: release-android
|
||||
3
.github/workflows/publish-store.yml
vendored
3
.github/workflows/publish-store.yml
vendored
@@ -169,8 +169,9 @@ jobs:
|
||||
- name: Enable Publish Github Release Workflow
|
||||
env:
|
||||
PRODUCT: ${{ inputs.product }}
|
||||
DRY_RUN: ${{ inputs.dry-run }}
|
||||
run: |
|
||||
if ${{ inputs.dry-run }} ; then
|
||||
if $DRY_RUN ; then
|
||||
gh workflow view publish-github-release-bwpm.yml
|
||||
exit 0
|
||||
fi
|
||||
|
||||
2
.github/workflows/sdlc-sdk-update.yml
vendored
2
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-pull-requests: write
|
||||
permission-actions: read
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<!-- CRL Distribution Servers -->
|
||||
<domain includeSubdomains="true">c.lencr.org</domain>
|
||||
<domain includeSubdomains="true">c.pki.goog</domain>
|
||||
<domain includeSubdomains="true">crls.certainly.com</domain>
|
||||
|
||||
<!-- OCSP Responder Servers -->
|
||||
<domain includeSubdomains="true">o.pki.goog</domain>
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
<data android:scheme="https" />
|
||||
<data android:host="bitwarden.com" />
|
||||
<data android:host="bitwarden.eu" />
|
||||
<data android:host="bitwarden.pw" />
|
||||
<data android:pathPattern="/duo-callback" />
|
||||
<data android:pathPattern="/sso-callback" />
|
||||
<data android:pathPattern="/webauthn-callback" />
|
||||
|
||||
@@ -645,6 +645,10 @@
|
||||
"info": {
|
||||
"package_name": "com.heytap.browser",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "B2:9A:A0:BB:DC:9F:D9:DE:F5:5D:C5:6E:A7:D7:45:76:D5:84:6C:BC:F5:E5:AB:D3:05:E2:D9:31:9E:4F:42:AE"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5"
|
||||
@@ -656,6 +660,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.oplus.credential",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "E4:98:02:40:95:84:CE:53:15:2A:90:00:82:0A:51:E4:FA:8A:72:3B:7B:CC:26:3E:33:52:40:AC:F1:00:BF:9E"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -15,13 +15,13 @@ import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
@@ -36,15 +36,11 @@ 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.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.overlaynav.OverlayNavRoute
|
||||
import com.x8bit.bitwarden.ui.platform.feature.overlaynav.overlayNavDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
|
||||
import com.x8bit.bitwarden.ui.platform.util.appLanguage
|
||||
@@ -130,45 +126,13 @@ class MainActivity : AppCompatActivity() {
|
||||
SetupEventsEffect(navController = navController)
|
||||
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
|
||||
LocalManagerProvider(
|
||||
featureFlagsState = state.featureFlagsState,
|
||||
MainActivityContent(
|
||||
state = state,
|
||||
authTabLaunchers = authTabLaunchers,
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
onDataUpdate = remember(mainViewModel) {
|
||||
{ mainViewModel.trySendAction(MainAction.ResumeScreenDataReceived(it)) }
|
||||
},
|
||||
)
|
||||
BitwardenTheme(
|
||||
theme = state.theme,
|
||||
dynamicColor = state.isDynamicColorsEnabled,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
modifier = Modifier
|
||||
.background(color = BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// Root navigation, debug menu, and cookie acquisition exist at
|
||||
// this top level. They can appear on top of the rest of the app
|
||||
// without interacting with the state-based navigation used by
|
||||
// RootNavScreen.
|
||||
rootNavDestination { shouldShowSplashScreen = false }
|
||||
debugMenuDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
cookieAcquisitionDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
localNetworkAccessDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
navController = navController,
|
||||
sendAction = mainViewModel::trySendAction,
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +164,7 @@ class MainActivity : AppCompatActivity() {
|
||||
locales.get(0)?.appLanguage
|
||||
}
|
||||
} else {
|
||||
// For older versions, use what ever language is available from the repository.
|
||||
// For older versions, use whatever language is available from the repository.
|
||||
settingsRepository.appLanguage
|
||||
}
|
||||
|
||||
@@ -249,11 +213,6 @@ class MainActivity : AppCompatActivity() {
|
||||
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
|
||||
MainEvent.Recreate -> handleRecreate()
|
||||
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
|
||||
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
|
||||
MainEvent.NavigateToLocalNetworkAccess -> {
|
||||
navController.navigateToLocalNetworkAccess()
|
||||
}
|
||||
|
||||
is MainEvent.UpdateAppLocale -> {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(event.localeName),
|
||||
@@ -297,3 +256,38 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OmitFromCoverage
|
||||
@Composable
|
||||
private fun MainActivityContent(
|
||||
state: MainState,
|
||||
authTabLaunchers: AuthTabLaunchers,
|
||||
navController: NavHostController,
|
||||
sendAction: (MainAction) -> Unit,
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
LocalManagerProvider(
|
||||
featureFlagsState = state.featureFlagsState,
|
||||
authTabLaunchers = authTabLaunchers,
|
||||
) {
|
||||
ObserveScreenDataEffect { sendAction(MainAction.ResumeScreenDataReceived(it)) }
|
||||
BitwardenTheme(
|
||||
theme = state.theme,
|
||||
dynamicColor = state.isDynamicColorsEnabled,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = OverlayNavRoute,
|
||||
modifier = Modifier.background(BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// The OverlayNav and Debug destinations are the only UIs that can be
|
||||
// displayed here, everything else should be inside the OverlayNav.
|
||||
overlayNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
|
||||
debugMenuDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = onSplashScreenRemoved,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,18 +31,17 @@ import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
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
|
||||
@@ -56,7 +55,6 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -80,8 +78,6 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
|
||||
class MainViewModel @Inject constructor(
|
||||
accessibilitySelectionManager: AccessibilitySelectionManager,
|
||||
autofillSelectionManager: AutofillSelectionManager,
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
networkPermissionManager: NetworkPermissionManager,
|
||||
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
@@ -170,20 +166,6 @@ class MainViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
networkPermissionManager
|
||||
.isLocalNetworkAccessRequiredStateFlow
|
||||
.filter { it }
|
||||
.map { MainAction.Internal.LocalNetworkAccessRequired }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
cookieAcquisitionRequestManager
|
||||
.cookieAcquisitionRequestFlow
|
||||
.filterNotNull()
|
||||
.map { MainAction.Internal.CookieAcquisitionReady }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// On app launch, mark all active users as having previously logged in.
|
||||
// This covers any users who are active prior to this value being recorded.
|
||||
viewModelScope.launch {
|
||||
@@ -232,8 +214,6 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -315,14 +295,6 @@ class MainViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionReady() {
|
||||
sendEvent(MainEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleLocalNetworkAccessRequired() {
|
||||
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
|
||||
}
|
||||
|
||||
private fun handleResizeHasBeenRequested() {
|
||||
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
|
||||
}
|
||||
@@ -677,17 +649,6 @@ sealed class MainAction {
|
||||
val isDynamicColorsEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the cookie acquisition conditions are met and navigation
|
||||
* should proceed.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -721,16 +682,6 @@ sealed class MainEvent {
|
||||
*/
|
||||
data object NavigateToDebugMenu : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the cookie acquisition screen.
|
||||
*/
|
||||
data object NavigateToCookieAcquisition : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the local network access screen.
|
||||
*/
|
||||
data object NavigateToLocalNetworkAccess : MainEvent()
|
||||
|
||||
/**
|
||||
* Indicates that the app language has been updated.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.disk
|
||||
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.provider.AppIdProvider
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
||||
@@ -114,16 +114,6 @@ interface AuthDiskSource : AppIdProvider {
|
||||
invalidUnlockAttempts: Int?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves a user key using a [userId].
|
||||
*/
|
||||
fun getUserKey(userId: String): String?
|
||||
|
||||
/**
|
||||
* Stores a user key using a [userId].
|
||||
*/
|
||||
fun storeUserKey(userId: String, userKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves the local user data key for the given [userId].
|
||||
*/
|
||||
@@ -135,34 +125,16 @@ interface AuthDiskSource : AppIdProvider {
|
||||
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves a private key using a [userId].
|
||||
* Returns the Wrapped Account Cryptographic State for the given [userId].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Use getAccountKeys instead.",
|
||||
replaceWith = ReplaceWith("getAccountKeys"),
|
||||
)
|
||||
fun getPrivateKey(userId: String): String?
|
||||
fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState?
|
||||
|
||||
/**
|
||||
* Stores a private key using a [userId].
|
||||
* Stores the Wrapped Account Cryptographic State for a given [userId].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Use storeAccountKeys instead.",
|
||||
replaceWith = ReplaceWith("storeAccountKeys"),
|
||||
)
|
||||
fun storePrivateKey(userId: String, privateKey: String?)
|
||||
|
||||
/**
|
||||
* Returns the profile account keys for the given [userId].
|
||||
*/
|
||||
fun getAccountKeys(userId: String): AccountKeysJson?
|
||||
|
||||
/**
|
||||
* Stores the profile account keys for the given [userId].
|
||||
*/
|
||||
fun storeAccountKeys(
|
||||
fun storeAccountCryptographicState(
|
||||
userId: String,
|
||||
accountKeys: AccountKeysJson?,
|
||||
accountCryptographicState: WrappedAccountCryptographicState?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.serializer.SafeMapSerializer
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
@@ -11,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.serializer.WrappedAccountCryptographicStateSerializer
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -54,6 +57,7 @@ private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
|
||||
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
|
||||
private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
|
||||
private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys"
|
||||
private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
@@ -91,6 +95,10 @@ class AuthDiskSourceImpl(
|
||||
mutableMapOf<String, MutableSharedFlow<String?>>()
|
||||
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
|
||||
|
||||
private val wrappedAccountCryptographicStateSerializer by lazy {
|
||||
WrappedAccountCryptographicStateSerializer()
|
||||
}
|
||||
|
||||
override var userState: UserStateJson?
|
||||
get() = getString(key = STATE_KEY)?.let { json.decodeFromStringOrNull(it) }
|
||||
set(value) {
|
||||
@@ -109,6 +117,14 @@ class AuthDiskSourceImpl(
|
||||
// We must migrate the tokens from being stored in the UserState(shared preferences) to
|
||||
// being stored separately in encrypted shared preferences.
|
||||
migrateAccountTokens()
|
||||
|
||||
// We want to make sure that any left over encrypted user keys are scrubbed from storage
|
||||
// Since it is no longer supported.
|
||||
removeLegacyUserKeys()
|
||||
|
||||
// We must migrate the Private Key and Account Keys to use the Account Cryptographic state
|
||||
// from now on.
|
||||
migrateAccountKeys()
|
||||
}
|
||||
|
||||
override var authenticatorSyncSymmetricKey: ByteArray?
|
||||
@@ -146,11 +162,9 @@ class AuthDiskSourceImpl(
|
||||
|
||||
override fun clearData(userId: String) {
|
||||
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
|
||||
storeUserKey(userId = userId, userKey = null)
|
||||
storeLocalUserDataKey(userId = userId, wrappedKey = null)
|
||||
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
|
||||
storePrivateKey(userId = userId, privateKey = null)
|
||||
storeAccountKeys(userId = userId, accountKeys = null)
|
||||
storeAccountCryptographicState(userId = userId, accountCryptographicState = null)
|
||||
storeOrganizationKeys(userId = userId, organizationKeys = null)
|
||||
storeOrganizations(userId = userId, organizations = null)
|
||||
storeUserBiometricInitVector(userId = userId, iv = null)
|
||||
@@ -231,16 +245,6 @@ class AuthDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getUserKey(userId: String): String? =
|
||||
getString(key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId))
|
||||
|
||||
override fun storeUserKey(userId: String, userKey: String?) {
|
||||
putString(
|
||||
key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId),
|
||||
value = userKey,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLocalUserDataKey(userId: String): String? =
|
||||
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
|
||||
|
||||
@@ -248,29 +252,20 @@ class AuthDiskSourceImpl(
|
||||
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
|
||||
}
|
||||
|
||||
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
|
||||
override fun getPrivateKey(userId: String): String? =
|
||||
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
|
||||
override fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState? =
|
||||
getEncryptedString(key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId))?.let {
|
||||
json.decodeFromStringOrNull(wrappedAccountCryptographicStateSerializer, it)
|
||||
}
|
||||
|
||||
@Deprecated("Use storeAccountKeys instead.", replaceWith = ReplaceWith("storeAccountKeys"))
|
||||
override fun storePrivateKey(userId: String, privateKey: String?) {
|
||||
putString(
|
||||
key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId),
|
||||
value = privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAccountKeys(userId: String): AccountKeysJson? =
|
||||
getEncryptedString(key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId))
|
||||
?.let { json.decodeFromStringOrNull(it) }
|
||||
|
||||
override fun storeAccountKeys(
|
||||
override fun storeAccountCryptographicState(
|
||||
userId: String,
|
||||
accountKeys: AccountKeysJson?,
|
||||
accountCryptographicState: WrappedAccountCryptographicState?,
|
||||
) {
|
||||
putEncryptedString(
|
||||
key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId),
|
||||
value = accountKeys?.let { json.encodeToString(it) },
|
||||
key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId),
|
||||
value = accountCryptographicState?.let {
|
||||
json.encodeToString(wrappedAccountCryptographicStateSerializer, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,4 +657,35 @@ class AuthDiskSourceImpl(
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeLegacyUserKeys() {
|
||||
removeWithPrefix(prefix = MASTER_KEY_ENCRYPTION_USER_KEY)
|
||||
}
|
||||
|
||||
private fun migrateAccountKeys() {
|
||||
userState
|
||||
?.accounts
|
||||
.orEmpty()
|
||||
.values
|
||||
.forEach { account ->
|
||||
val userId = account.profile.userId
|
||||
val accountKeysKey = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId)
|
||||
val privateKeyKey = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId)
|
||||
val accountKeys = getEncryptedString(key = accountKeysKey)
|
||||
?.let { json.decodeFromStringOrNull<AccountKeysJson>(it) }
|
||||
val privateKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.wrappedPrivateKey
|
||||
?: getString(key = privateKeyKey)
|
||||
privateKey?.let {
|
||||
storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(it),
|
||||
)
|
||||
// Remove the Account Keys and Private Key
|
||||
putEncryptedString(key = accountKeysKey, value = null)
|
||||
putString(key = privateKeyKey, value = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.disk.serializer
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Custom [KSerializer] for [WrappedAccountCryptographicState].
|
||||
*
|
||||
* Encodes the sealed class with a `"type"` discriminator field:
|
||||
* - `"v1"`: [WrappedAccountCryptographicState.V1] — wrapped private key only.
|
||||
* - `"v2"`: [WrappedAccountCryptographicState.V2] — wrapped private key, signing key, signed
|
||||
* public key, and signed security state.
|
||||
*/
|
||||
internal class WrappedAccountCryptographicStateSerializer :
|
||||
KSerializer<WrappedAccountCryptographicState> {
|
||||
|
||||
private val surrogateSerializer = Surrogate.serializer()
|
||||
|
||||
override val descriptor: SerialDescriptor = surrogateSerializer.descriptor
|
||||
|
||||
override fun deserialize(decoder: Decoder): WrappedAccountCryptographicState =
|
||||
when (val surrogate = decoder.decodeSerializableValue(surrogateSerializer)) {
|
||||
is Surrogate.V1 -> {
|
||||
WrappedAccountCryptographicState.V1(privateKey = surrogate.privateKey)
|
||||
}
|
||||
|
||||
is Surrogate.V2 -> {
|
||||
WrappedAccountCryptographicState.V2(
|
||||
privateKey = surrogate.privateKey,
|
||||
signingKey = surrogate.signingKey,
|
||||
signedPublicKey = surrogate.signedPublicKey,
|
||||
securityState = surrogate.securityState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: WrappedAccountCryptographicState) {
|
||||
val surrogate = when (value) {
|
||||
is WrappedAccountCryptographicState.V1 -> {
|
||||
Surrogate.V1(privateKey = value.privateKey)
|
||||
}
|
||||
|
||||
is WrappedAccountCryptographicState.V2 -> {
|
||||
Surrogate.V2(
|
||||
privateKey = value.privateKey,
|
||||
signingKey = value.signingKey,
|
||||
signedPublicKey = value.signedPublicKey,
|
||||
securityState = value.securityState,
|
||||
)
|
||||
}
|
||||
}
|
||||
encoder.encodeSerializableValue(surrogateSerializer, surrogate)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private sealed class Surrogate {
|
||||
@Serializable
|
||||
@SerialName("v1")
|
||||
data class V1(
|
||||
@SerialName("privateKey")
|
||||
val privateKey: String,
|
||||
) : Surrogate()
|
||||
|
||||
@Serializable
|
||||
@SerialName("v2")
|
||||
data class V2(
|
||||
@SerialName("privateKey")
|
||||
val privateKey: String,
|
||||
|
||||
@SerialName("signingKey")
|
||||
val signingKey: String,
|
||||
|
||||
@SerialName("signedPublicKey")
|
||||
val signedPublicKey: String?,
|
||||
|
||||
@SerialName("securityState")
|
||||
val securityState: String,
|
||||
) : Surrogate()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.auth.manager
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
|
||||
|
||||
@@ -11,14 +10,6 @@ import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorR
|
||||
* Manager used to interface with a key connector.
|
||||
*/
|
||||
interface KeyConnectorManager {
|
||||
/**
|
||||
* Retrieves the master key from the key connector.
|
||||
*/
|
||||
suspend fun getMasterKeyFromKeyConnector(
|
||||
url: String,
|
||||
accessToken: String,
|
||||
): Result<KeyConnectorMasterKeyResponseJson>
|
||||
|
||||
/**
|
||||
* Migrates an existing user to use the key connector.
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
|
||||
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
|
||||
@@ -17,7 +15,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,17 +24,7 @@ class KeyConnectorManagerImpl(
|
||||
private val authSdkSource: AuthSdkSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : KeyConnectorManager {
|
||||
override suspend fun getMasterKeyFromKeyConnector(
|
||||
url: String,
|
||||
accessToken: String,
|
||||
): Result<KeyConnectorMasterKeyResponseJson> =
|
||||
accountsService.getMasterKeyFromKeyConnector(
|
||||
url = url,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
|
||||
override suspend fun migrateExistingUserToKeyConnector(
|
||||
userId: String,
|
||||
url: String,
|
||||
@@ -97,26 +84,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
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.core.AuthRequestMethod
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.MasterPasswordUnlockData
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
@@ -25,9 +26,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 +39,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 +52,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
|
||||
@@ -103,12 +105,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.accountKeysJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.privateKey
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toKdfRequestModel
|
||||
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
|
||||
@@ -132,6 +133,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -152,7 +154,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
|
||||
@@ -189,7 +190,7 @@ class AuthRepositoryImpl(
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
@@ -312,6 +313,7 @@ class AuthRepositoryImpl(
|
||||
override val organizations: List<Organization>
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getOrganizations(it) }
|
||||
?.filter { it.status == OrganizationStatusType.CONFIRMED }
|
||||
.orEmpty()
|
||||
.toOrganizations()
|
||||
|
||||
@@ -366,7 +368,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
|
||||
|
||||
@@ -534,17 +536,13 @@ class AuthRepositoryImpl(
|
||||
.map { registerTdeKeyResponse to createAccountKeysResponse }
|
||||
}
|
||||
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
authDiskSource.storeAccountKeys(
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountKeys = createAccountKeysResponse.accountKeys,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
|
||||
// have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
accountCryptographicState = createAccountKeysResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
),
|
||||
)
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
registerTdeKeyResponse.deviceKey?.let { response ->
|
||||
@@ -563,15 +561,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)
|
||||
@@ -583,25 +580,16 @@ class AuthRepositoryImpl(
|
||||
decryptedUserKey = response.userKey,
|
||||
),
|
||||
)
|
||||
.also { result ->
|
||||
if (result is VaultUnlockResult.Success) {
|
||||
authDiskSource.storeAccountKeys(
|
||||
.onVaultUnlockSuccess {
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountCryptographicState = response.accountCryptographicState,
|
||||
)
|
||||
if (shouldTrustDevice) {
|
||||
authDiskSource.storeDeviceKey(
|
||||
userId = userId,
|
||||
accountKeys = response.accountCryptographicState.accountKeysJson,
|
||||
deviceKey = response.deviceKey,
|
||||
)
|
||||
|
||||
// Storing the private key here for legacy purposes, the
|
||||
// `accountKeysJson` stored above will be used for most purposes.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = response.accountCryptographicState.privateKey,
|
||||
)
|
||||
if (shouldTrustDevice) {
|
||||
authDiskSource.storeDeviceKey(
|
||||
userId = userId,
|
||||
deviceKey = response.deviceKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,25 +600,18 @@ class AuthRepositoryImpl(
|
||||
asymmetricalKey: String,
|
||||
): LoginResult {
|
||||
val profile = authDiskSource.userState?.activeAccount?.profile
|
||||
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
|
||||
?: return LoginResult.Error(error = NoActiveUserException())
|
||||
val userId = profile.userId
|
||||
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
|
||||
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
|
||||
?: authDiskSource.getPrivateKey(userId = userId)
|
||||
?: return LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Private Key"),
|
||||
)
|
||||
|
||||
val accountCryptographicState = authDiskSource
|
||||
.getAccountCryptographicState(userId = userId)
|
||||
?: return LoginResult.Error(MissingPropertyException("Account Cryptographic State"))
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { error ->
|
||||
return error.toLoginErrorResult()
|
||||
},
|
||||
) {
|
||||
unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
),
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = requestPrivateKey,
|
||||
@@ -670,7 +651,7 @@ class AuthRepositoryImpl(
|
||||
onFailure = { throwable ->
|
||||
when {
|
||||
throwable.isSslHandShakeError() -> LoginResult.CertificateError
|
||||
else -> LoginResult.Error(errorMessage = null, error = throwable)
|
||||
else -> LoginResult.Error(error = throwable)
|
||||
}
|
||||
},
|
||||
onSuccess = { it },
|
||||
@@ -715,10 +696,7 @@ class AuthRepositoryImpl(
|
||||
orgIdentifier = orgIdentifier,
|
||||
)
|
||||
}
|
||||
?: LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Identity Token Auth Model"),
|
||||
)
|
||||
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
@@ -736,17 +714,13 @@ class AuthRepositoryImpl(
|
||||
orgIdentifier = orgIdentifier,
|
||||
)
|
||||
}
|
||||
?: LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Identity Token Auth Model"),
|
||||
)
|
||||
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
|
||||
|
||||
override suspend fun continueKeyConnectorLogin(
|
||||
orgIdentifier: String,
|
||||
email: String,
|
||||
): LoginResult {
|
||||
val response = keyConnectorResponse ?: return LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Key Connector Response"),
|
||||
)
|
||||
return handleLoginCommonSuccess(
|
||||
@@ -974,15 +948,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) },
|
||||
@@ -1047,18 +1020,20 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
|
||||
val activeAccount = authDiskSource
|
||||
val profile = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
?.profile
|
||||
?: return RemovePasswordResult.Error(error = NoActiveUserException())
|
||||
val profile = activeAccount.profile
|
||||
val userId = profile.userId
|
||||
val userKey = authDiskSource
|
||||
.getUserKey(userId = userId)
|
||||
val userKey = profile
|
||||
.userDecryptionOptions
|
||||
?.masterPasswordUnlock
|
||||
?.masterKeyWrappedUserKey
|
||||
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
|
||||
val keyConnectorUrl = organizations
|
||||
.find {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.isKeyConnectorEnabled &&
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
}
|
||||
@@ -1105,16 +1080,14 @@ class AuthRepositoryImpl(
|
||||
newPassword: String,
|
||||
passwordHint: String?,
|
||||
): ResetPasswordResult {
|
||||
val activeAccount = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
val profile = authDiskSource.userState?.activeAccount?.profile
|
||||
?: return ResetPasswordResult.Error(error = NoActiveUserException())
|
||||
val currentPasswordHash = currentPassword?.let { password ->
|
||||
authSdkSource
|
||||
.hashPassword(
|
||||
email = activeAccount.profile.email,
|
||||
email = profile.email,
|
||||
password = password,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
kdf = profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
.fold(
|
||||
@@ -1122,48 +1095,32 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { it },
|
||||
)
|
||||
}
|
||||
val userId = activeAccount.profile.userId
|
||||
val userId = profile.userId
|
||||
return vaultSdkSource
|
||||
.updatePassword(
|
||||
userId = userId,
|
||||
newPassword = newPassword,
|
||||
)
|
||||
.flatMap { updatePasswordResponse ->
|
||||
accountsService
|
||||
.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = updatePasswordResponse.passwordHash,
|
||||
passwordHint = passwordHint,
|
||||
key = updatePasswordResponse.newKey,
|
||||
),
|
||||
)
|
||||
.flatMap { response ->
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
passwordHint = passwordHint,
|
||||
kdf = profile.toKdfRequestModel(),
|
||||
salt = profile.email,
|
||||
masterPasswordAuthenticationHash = response.passwordHash,
|
||||
masterKeyWrappedUserKey = response.newKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
toastManager.show(BitwardenString.updated_master_password)
|
||||
// Log out the user after successful password reset. This clears all
|
||||
// user data, so there is no need to store any of the updated info.
|
||||
logout(reason = LogoutReason.PasswordReset, userId = userId)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = {
|
||||
// Update the saved master password hash.
|
||||
authSdkSource
|
||||
.hashPassword(
|
||||
email = activeAccount.profile.email,
|
||||
password = newPassword,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
purpose = HashPurpose.LOCAL_AUTHORIZATION,
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = userId,
|
||||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
toastManager.show(BitwardenString.updated_master_password)
|
||||
// Log out the user after successful password reset.
|
||||
// This clears all user state including forcePasswordResetReason.
|
||||
logout(reason = LogoutReason.PasswordReset, userId = userId)
|
||||
|
||||
// Return the success.
|
||||
ResetPasswordResult.Success
|
||||
},
|
||||
onSuccess = { ResetPasswordResult.Success },
|
||||
onFailure = { ResetPasswordResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
@@ -1172,10 +1129,10 @@ class AuthRepositoryImpl(
|
||||
organizationIdentifier: String,
|
||||
password: String,
|
||||
passwordHint: String?,
|
||||
): SetPasswordResult {
|
||||
): SetPasswordResult = userStateManager.userStateTransaction {
|
||||
val profile = authDiskSource.userState?.activeAccount?.profile
|
||||
?: return SetPasswordResult.Error(error = NoActiveUserException())
|
||||
return when (profile.forcePasswordResetReason) {
|
||||
?: return@userStateTransaction SetPasswordResult.Error(error = NoActiveUserException())
|
||||
return@userStateTransaction when (profile.forcePasswordResetReason) {
|
||||
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
|
||||
setUpdatedPassword(
|
||||
profile = profile,
|
||||
@@ -1211,45 +1168,34 @@ class AuthRepositoryImpl(
|
||||
.flatMap { response ->
|
||||
accountsService
|
||||
.setPassword(
|
||||
body = SetPasswordRequestJson(
|
||||
passwordHash = response.passwordHash,
|
||||
body = SetPasswordRequestJson.V2(
|
||||
passwordHint = passwordHint,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
kdfIterations = profile.kdfIterations,
|
||||
kdfMemory = profile.kdfMemory,
|
||||
kdfParallelism = profile.kdfParallelism,
|
||||
kdfType = profile.kdfType,
|
||||
key = response.newKey,
|
||||
keys = null,
|
||||
kdf = profile.toKdfRequestModel(),
|
||||
salt = profile.email,
|
||||
masterPasswordAuthenticationHash = response.passwordHash,
|
||||
masterKeyWrappedUserKey = response.newKey,
|
||||
),
|
||||
)
|
||||
.onSuccess {
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = response.newKey)
|
||||
}
|
||||
.map { response.passwordHash }
|
||||
.map { response }
|
||||
}
|
||||
.flatMap { masterPasswordHash ->
|
||||
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
|
||||
is VaultUnlockResult.Success -> {
|
||||
enrollUserInPasswordReset(
|
||||
userId = userId,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
passwordHash = masterPasswordHash,
|
||||
)
|
||||
}
|
||||
|
||||
is VaultUnlockError -> {
|
||||
(result.error ?: IllegalStateException("Failed to unlock vault"))
|
||||
.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
.onSuccess { response ->
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = null,
|
||||
masterPasswordUnlock = MasterPasswordUnlockData(
|
||||
kdf = profile.toSdkParams(),
|
||||
masterKeyWrappedUserKey = response.newKey,
|
||||
salt = profile.email,
|
||||
),
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.flatMap { response ->
|
||||
enrollUserInPasswordReset(
|
||||
userId = userId,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
passwordHash = response.passwordHash,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { SetPasswordResult.Error(error = it) },
|
||||
onSuccess = { SetPasswordResult.Success },
|
||||
@@ -1279,31 +1225,21 @@ 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(
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountKeys = response.accountCryptographicState.accountKeysJson,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = response.accountCryptographicState.privateKey,
|
||||
accountCryptographicState = response.accountCryptographicState,
|
||||
)
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = response.masterPasswordUnlock,
|
||||
@@ -1344,7 +1280,7 @@ class AuthRepositoryImpl(
|
||||
.flatMap { response ->
|
||||
accountsService
|
||||
.setPassword(
|
||||
body = SetPasswordRequestJson(
|
||||
body = SetPasswordRequestJson.V1(
|
||||
passwordHash = response.masterPasswordHash,
|
||||
passwordHint = passwordHint,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
@@ -1353,34 +1289,39 @@ class AuthRepositoryImpl(
|
||||
kdfParallelism = profile.kdfParallelism,
|
||||
kdfType = profile.kdfType,
|
||||
key = response.encryptedUserKey,
|
||||
keys = SetPasswordRequestJson.Keys(
|
||||
keys = SetPasswordRequestJson.V1.Keys(
|
||||
publicKey = response.keys.public,
|
||||
encryptedPrivateKey = response.keys.private,
|
||||
),
|
||||
),
|
||||
)
|
||||
.onSuccess {
|
||||
// This process is used by TDE and Enterprise accounts during initial
|
||||
// login. We continue to store the locally generated keys
|
||||
// until TDE and Enterprise accounts support AEAD keys.
|
||||
authDiskSource.storePrivateKey(
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
privateKey = response.keys.private,
|
||||
)
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userId,
|
||||
userKey = response.encryptedUserKey,
|
||||
accountCryptographicState = WrappedAccountCryptographicState.V1(
|
||||
privateKey = response.keys.private,
|
||||
),
|
||||
)
|
||||
authDiskSource.userState = authDiskSource
|
||||
.userState
|
||||
?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = MasterPasswordUnlockData(
|
||||
kdf = profile.toSdkParams(),
|
||||
masterKeyWrappedUserKey = response.encryptedUserKey,
|
||||
salt = profile.email,
|
||||
),
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.map { response.masterPasswordHash }
|
||||
.map { response }
|
||||
}
|
||||
.flatMap { masterPasswordHash ->
|
||||
.flatMap { response ->
|
||||
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
|
||||
is VaultUnlockResult.Success -> {
|
||||
enrollUserInPasswordReset(
|
||||
userId = userId,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
passwordHash = masterPasswordHash,
|
||||
passwordHash = response.masterPasswordHash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1390,12 +1331,6 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.fold(
|
||||
onFailure = { SetPasswordResult.Error(error = it) },
|
||||
onSuccess = { SetPasswordResult.Success },
|
||||
@@ -1516,7 +1451,12 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
|
||||
override suspend fun validatePassword(password: String): ValidatePasswordResult {
|
||||
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
|
||||
val profile = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
?.profile
|
||||
?: return ValidatePasswordResult.Error(error = NoActiveUserException())
|
||||
val userId = profile.userId
|
||||
return authDiskSource
|
||||
.getMasterPasswordHash(userId = userId)
|
||||
?.let { masterPasswordHash ->
|
||||
@@ -1532,8 +1472,10 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
val encryptedKey = authDiskSource
|
||||
.getUserKey(userId)
|
||||
val encryptedKey = profile
|
||||
.userDecryptionOptions
|
||||
?.masterPasswordUnlock
|
||||
?.masterKeyWrappedUserKey
|
||||
?: return ValidatePasswordResult.Error(MissingPropertyException("UserKey"))
|
||||
vaultSdkSource
|
||||
.validatePasswordUserKey(
|
||||
@@ -1551,8 +1493,8 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { ValidatePasswordResult.Success(isValid = true) },
|
||||
onFailure = {
|
||||
// We currently assume that all errors are caused by the user entering
|
||||
// an invalid password, this is not necessarily the case but we have no
|
||||
// way to differentiate between the different errors.
|
||||
// an invalid password, this is not necessarily the case, but we have
|
||||
// no way to differentiate between the different errors.
|
||||
ValidatePasswordResult.Success(isValid = false)
|
||||
},
|
||||
)
|
||||
@@ -1706,7 +1648,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.
|
||||
@@ -1816,10 +1758,7 @@ class AuthRepositoryImpl(
|
||||
LoginResult.UnofficialServerError
|
||||
}
|
||||
|
||||
else -> LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = throwable,
|
||||
)
|
||||
else -> LoginResult.Error(error = throwable)
|
||||
}
|
||||
},
|
||||
onSuccess = { loginResponse ->
|
||||
@@ -1883,6 +1822,14 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
val profile = userStateJson.activeAccount.profile
|
||||
val userId = profile.userId
|
||||
authDiskSource.storeAccountTokens(
|
||||
userId = userId,
|
||||
accountTokens = AccountTokensJson(
|
||||
accessToken = loginResponse.accessToken,
|
||||
refreshToken = loginResponse.refreshToken,
|
||||
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
|
||||
),
|
||||
)
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { vaultUnlockError ->
|
||||
@@ -1915,6 +1862,7 @@ class AuthRepositoryImpl(
|
||||
// If a new KeyConnector user is logging in for the first time,
|
||||
// we should ask him to confirm the domain
|
||||
if (isNewKeyConnectorUser && isNotConfirmed) {
|
||||
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
|
||||
keyConnectorResponse = loginResponse
|
||||
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
|
||||
domain = keyConnectorUrl,
|
||||
@@ -1956,16 +1904,7 @@ class AuthRepositoryImpl(
|
||||
passwordsToCheckMap.put(userId, it)
|
||||
}
|
||||
|
||||
authDiskSource.storeAccountTokens(
|
||||
userId = userId,
|
||||
accountTokens = AccountTokensJson(
|
||||
accessToken = loginResponse.accessToken,
|
||||
refreshToken = loginResponse.refreshToken,
|
||||
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
|
||||
),
|
||||
)
|
||||
settingsRepository.hasUserLoggedInOrCreatedAccount = true
|
||||
|
||||
authDiskSource.userState = userStateJson
|
||||
password?.let {
|
||||
// Automatically update kdf to minimums after password unlock and userState update
|
||||
@@ -1977,22 +1916,16 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
}
|
||||
}
|
||||
loginResponse.key?.let {
|
||||
// Only set the value if it's present, since we may have set it already
|
||||
// when we completed the pending admin auth request.
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = it)
|
||||
}
|
||||
// We continue to store the private key for backwards compatibility. Key connector
|
||||
// conversion still relies on the private key.
|
||||
loginResponse.privateKeyOrNull()?.let {
|
||||
// Only set the value if it's present, since we may have set it already
|
||||
// when we completed the key connector conversion.
|
||||
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
|
||||
}
|
||||
loginResponse.accountKeys?.let {
|
||||
// Only set the value if it's present, since we may have set it already
|
||||
// when we completed the key connector conversion.
|
||||
authDiskSource.storeAccountKeys(userId = userId, accountKeys = it)
|
||||
|
||||
loginResponse.privateKeyOrNull()?.let { privateKey ->
|
||||
// Only set the value if the private key is present, since we may have set
|
||||
// the value already when we completed the key connector conversion.
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
// If the user just authenticated with a two-factor code and selected the option to
|
||||
// remember it, then the API response will return a token that will be used in place
|
||||
@@ -2081,28 +2014,16 @@ class AuthRepositoryImpl(
|
||||
null
|
||||
} else if (key != null && privateKey != null) {
|
||||
// This is a returning user who should already have the key connector setup
|
||||
keyConnectorManager
|
||||
.getMasterKeyFromKeyConnector(
|
||||
unlockVault(
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnectorUrl(
|
||||
url = keyConnectorUrl,
|
||||
accessToken = loginResponse.accessToken,
|
||||
)
|
||||
.map {
|
||||
unlockVault(
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = it.masterKey,
|
||||
userKey = key,
|
||||
),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
// If the request failed, we want to abort the login process
|
||||
onFailure = { VaultUnlockResult.GenericError(error = it) },
|
||||
onSuccess = { it },
|
||||
)
|
||||
keyConnectorKeyWrappedUserKey = key,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// This is a new user who needs to set up the key connector
|
||||
val userId = profile.userId
|
||||
@@ -2119,35 +2040,21 @@ class AuthRepositoryImpl(
|
||||
organizationIdentifier = orgIdentifier,
|
||||
)
|
||||
.map { keyConnector ->
|
||||
val accountCryptographicState = keyConnector.accountCryptographicState
|
||||
this
|
||||
.unlockVault(
|
||||
accountCryptographicState = keyConnector.accountCryptographicState,
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = keyConnector.masterKey,
|
||||
userKey = keyConnector.encryptedUserKey,
|
||||
),
|
||||
)
|
||||
.also { result ->
|
||||
if (result is VaultUnlockResult.Success) {
|
||||
// We now know that login/unlock was successful, so we store the
|
||||
// userKey and privateKey we now have since it didn't exist on the
|
||||
// loginResponse.
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userId,
|
||||
userKey = keyConnector.encryptedUserKey,
|
||||
)
|
||||
// We continue to store the private key for backwards compatibility
|
||||
// since key connector conversion still relies on the private key.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = keyConnector.privateKey,
|
||||
)
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
)
|
||||
}
|
||||
.onVaultUnlockSuccess {
|
||||
authDiskSource.storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
@@ -2222,8 +2129,8 @@ class AuthRepositoryImpl(
|
||||
),
|
||||
)
|
||||
// We are purposely not storing the master password hash here since it is not
|
||||
// formatted in in a manner that we can use. We will store it properly the next
|
||||
// time the user enters their master password and it is validated.
|
||||
// formatted in a manner that we can use. We will store it properly the next
|
||||
// time the user enters their master password, and it is validated.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2277,7 +2184,6 @@ class AuthRepositoryImpl(
|
||||
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
|
||||
),
|
||||
)
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
|
||||
}
|
||||
authDiskSource.storePendingAuthRequest(
|
||||
userId = userId,
|
||||
@@ -2307,10 +2213,6 @@ class AuthRepositoryImpl(
|
||||
deviceProtectedUserKey = encryptedUserKey,
|
||||
),
|
||||
)
|
||||
|
||||
if (vaultUnlockResult is VaultUnlockResult.Success) {
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
|
||||
}
|
||||
return vaultUnlockResult
|
||||
}
|
||||
|
||||
@@ -2333,7 +2235,7 @@ class AuthRepositoryImpl(
|
||||
// unlock the vault for organization data after receiving the sync response if this
|
||||
// data is currently absent. These keys may be present during certain multi-phase login
|
||||
// processes or if we needed to delete the user's token due to an encrypted data
|
||||
// corruption issue and they are forced to log back in.
|
||||
// corruption issue, and they are forced to log back in.
|
||||
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
|
||||
|
||||
/**
|
||||
* Models result of logging in.
|
||||
*/
|
||||
@@ -30,8 +32,8 @@ sealed class LoginResult {
|
||||
* There was an error logging in.
|
||||
*/
|
||||
data class Error(
|
||||
val errorMessage: String?,
|
||||
val error: Throwable?,
|
||||
val errorMessage: String? = error?.userFriendlyMessage,
|
||||
) : LoginResult()
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,5 +15,5 @@ fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
|
||||
is VaultUnlockResult.BiometricDecodingError,
|
||||
is VaultUnlockResult.GenericError,
|
||||
is VaultUnlockResult.InvalidStateError,
|
||||
-> LoginResult.Error(errorMessage = null, error = this.error)
|
||||
-> LoginResult.Error(error = this.error)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.KdfJson
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants
|
||||
@@ -13,8 +14,8 @@ fun AccountJson.Profile.toSdkParams(): Kdf {
|
||||
KdfTypeJson.ARGON2_ID -> Kdf.Argon2id(
|
||||
iterations = (kdfIterations ?: KdfParamsConstants.DEFAULT_ARGON2_ITERATIONS).toUInt(),
|
||||
memory = (kdfMemory ?: KdfParamsConstants.DEFAULT_ARGON2_MEMORY).toUInt(),
|
||||
parallelism =
|
||||
(kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM).toUInt(),
|
||||
parallelism = (kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM)
|
||||
.toUInt(),
|
||||
)
|
||||
|
||||
KdfTypeJson.PBKDF2_SHA256 -> Kdf.Pbkdf2(
|
||||
@@ -24,3 +25,23 @@ fun AccountJson.Profile.toSdkParams(): Kdf {
|
||||
else -> Kdf.Pbkdf2(iterations = KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS.toUInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [AccountJson.Profile] to [KdfJson] params for use with Bitwarden network requests.
|
||||
*/
|
||||
fun AccountJson.Profile.toKdfRequestModel(): KdfJson =
|
||||
when (val kdfType = this.kdfType ?: KdfTypeJson.PBKDF2_SHA256) {
|
||||
KdfTypeJson.ARGON2_ID -> KdfJson(
|
||||
kdfType = kdfType,
|
||||
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_ARGON2_ITERATIONS,
|
||||
memory = this.kdfMemory ?: KdfParamsConstants.DEFAULT_ARGON2_MEMORY,
|
||||
parallelism = this.kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
KdfTypeJson.PBKDF2_SHA256 -> KdfJson(
|
||||
kdfType = kdfType,
|
||||
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS,
|
||||
memory = this.kdfMemory,
|
||||
parallelism = this.kdfParallelism,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
|
||||
/**
|
||||
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
|
||||
@@ -15,9 +14,20 @@ import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCrypto
|
||||
*/
|
||||
fun AccountKeysJson?.toAccountCryptographicState(
|
||||
privateKey: String,
|
||||
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = this?.securityState?.securityState,
|
||||
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
|
||||
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
|
||||
)
|
||||
): WrappedAccountCryptographicState {
|
||||
val securityState = this?.securityState?.securityState
|
||||
val signingKey = this?.signatureKeyPair?.wrappedSigningKey
|
||||
val signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
return if (signingKey != null && securityState != null && signedPublicKey != null) {
|
||||
WrappedAccountCryptographicState.V2(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
)
|
||||
} else {
|
||||
WrappedAccountCryptographicState.V1(
|
||||
privateKey = privateKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,10 +5,12 @@ 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.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
|
||||
@@ -33,7 +35,10 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
|
||||
val profile = account.profile
|
||||
val updatedUserDecryptionOptions = profile
|
||||
.userDecryptionOptions
|
||||
?.copy(hasMasterPassword = false)
|
||||
?.copy(
|
||||
hasMasterPassword = false,
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
?: UserDecryptionOptionsJson(
|
||||
hasMasterPassword = false,
|
||||
trustedDeviceUserDecryptionOptions = null,
|
||||
@@ -67,7 +72,10 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
?.let { syncUserDecryption ->
|
||||
profile
|
||||
.userDecryptionOptions
|
||||
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
|
||||
?.copy(
|
||||
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
|
||||
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
|
||||
)
|
||||
?: UserDecryptionOptionsJson(
|
||||
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
|
||||
trustedDeviceUserDecryptionOptions = null,
|
||||
@@ -77,35 +85,52 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
}
|
||||
?: profile
|
||||
.userDecryptionOptions
|
||||
?.copy(masterPasswordUnlock = null)
|
||||
|
||||
val updatedProfile = profile
|
||||
.copy(
|
||||
avatarColorHex = syncProfile.avatarColor,
|
||||
stamp = syncProfile.securityStamp,
|
||||
hasPremiumPersonally = syncProfile.isPremium,
|
||||
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
|
||||
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
|
||||
creationDate = syncProfile.creationDate,
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
kdfType = masterPasswordUnlockKdf?.kdfType
|
||||
?: profile.kdfType,
|
||||
kdfIterations = masterPasswordUnlockKdf?.iterations
|
||||
?: profile.kdfIterations,
|
||||
kdfMemory = masterPasswordUnlockKdf?.memory
|
||||
?: profile.kdfMemory,
|
||||
kdfParallelism = masterPasswordUnlockKdf?.parallelism
|
||||
?: profile.kdfParallelism,
|
||||
)
|
||||
?.copy(
|
||||
hasMasterPassword = false,
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
val forcePasswordResetReason = syncProfile.getForcePasswordResetReason(
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
previousForcePasswordResetReason = profile.forcePasswordResetReason,
|
||||
)
|
||||
val updatedProfile = profile.copy(
|
||||
forcePasswordResetReason = forcePasswordResetReason,
|
||||
avatarColorHex = syncProfile.avatarColor,
|
||||
stamp = syncProfile.securityStamp,
|
||||
hasPremiumPersonally = syncProfile.isPremium,
|
||||
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
|
||||
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
|
||||
creationDate = syncProfile.creationDate,
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
kdfType = masterPasswordUnlockKdf?.kdfType ?: profile.kdfType,
|
||||
kdfIterations = masterPasswordUnlockKdf?.iterations ?: profile.kdfIterations,
|
||||
kdfMemory = masterPasswordUnlockKdf?.memory ?: profile.kdfMemory,
|
||||
kdfParallelism = masterPasswordUnlockKdf?.parallelism ?: profile.kdfParallelism,
|
||||
)
|
||||
val updatedAccount = account.copy(profile = updatedProfile)
|
||||
return this
|
||||
.copy(
|
||||
accounts = accounts
|
||||
.toMutableMap()
|
||||
.apply {
|
||||
replace(userId, updatedAccount)
|
||||
},
|
||||
)
|
||||
return this.copy(
|
||||
accounts = accounts
|
||||
.toMutableMap()
|
||||
.apply { replace(userId, updatedAccount) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncResponseJson.Profile.getForcePasswordResetReason(
|
||||
userDecryptionOptions: UserDecryptionOptionsJson?,
|
||||
previousForcePasswordResetReason: ForcePasswordResetReason?,
|
||||
): ForcePasswordResetReason? {
|
||||
val hasManageResetPasswordPermission = this.organizations.orEmpty().any {
|
||||
it.type == OrganizationType.OWNER ||
|
||||
it.type == OrganizationType.ADMIN ||
|
||||
it.permissions.shouldManageResetPassword
|
||||
}
|
||||
return ForcePasswordResetReason
|
||||
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION
|
||||
.takeIf {
|
||||
userDecryptionOptions?.hasMasterPassword == false &&
|
||||
hasManageResetPasswordPermission
|
||||
}
|
||||
?: previousForcePasswordResetReason
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,20 +138,16 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
* their password.
|
||||
*/
|
||||
fun UserStateJson.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock: MasterPasswordUnlockData?,
|
||||
masterPasswordUnlock: MasterPasswordUnlockData,
|
||||
): UserStateJson {
|
||||
val account = this.activeAccount
|
||||
val profile = account.profile
|
||||
val userDecryptionOptions = profile.userDecryptionOptions
|
||||
val masterPasswordUnlockJson = masterPasswordUnlock
|
||||
?.let {
|
||||
MasterPasswordUnlockDataJson(
|
||||
salt = it.salt,
|
||||
kdf = it.kdf.toKdfRequestModel(),
|
||||
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
|
||||
)
|
||||
}
|
||||
?: userDecryptionOptions?.masterPasswordUnlock
|
||||
val masterPasswordUnlockJson = MasterPasswordUnlockDataJson(
|
||||
salt = masterPasswordUnlock.salt,
|
||||
kdf = masterPasswordUnlock.kdf.toKdfRequestModel(),
|
||||
masterKeyWrappedUserKey = masterPasswordUnlock.masterKeyWrappedUserKey,
|
||||
)
|
||||
val updatedProfile = profile
|
||||
.copy(
|
||||
forcePasswordResetReason = null,
|
||||
@@ -192,7 +213,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,
|
||||
@@ -235,15 +256,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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -63,13 +63,16 @@ class BrowserThirdPartyAutofillManagerImpl(
|
||||
var thirdPartyEnabled = false
|
||||
val isThirdPartyAvailable = cursor
|
||||
?.use {
|
||||
it.moveToFirst()
|
||||
thirdPartyEnabled = it
|
||||
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
|
||||
.takeUnless { columnIndex -> columnIndex == -1 }
|
||||
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
|
||||
?: false
|
||||
true
|
||||
if (it.moveToFirst()) {
|
||||
thirdPartyEnabled = it
|
||||
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
|
||||
.takeUnless { columnIndex -> columnIndex == -1 }
|
||||
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
|
||||
?: false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
?: false
|
||||
return BrowserThirdPartyAutoFillData(
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.billing.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.billing.model.PremiumCard
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
|
||||
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -15,10 +17,9 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
|
||||
interface PremiumStateManager {
|
||||
|
||||
/**
|
||||
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
|
||||
* or `false` otherwise.
|
||||
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
|
||||
*/
|
||||
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
|
||||
val premiumCardStateFlow: StateFlow<PremiumCard>
|
||||
|
||||
/**
|
||||
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
|
||||
@@ -38,6 +39,11 @@ interface PremiumStateManager {
|
||||
*/
|
||||
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].
|
||||
@@ -66,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)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
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.model.PremiumCard
|
||||
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
|
||||
@@ -49,7 +52,7 @@ class PremiumStateManagerImpl(
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
vaultRepository: VaultRepository,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
pushManager: PushManager,
|
||||
private val clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -86,61 +89,105 @@ class PremiumStateManagerImpl(
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
|
||||
override val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState> =
|
||||
combine(
|
||||
authDiskSource.userStateFlow,
|
||||
billingRepository.isInAppBillingSupportedFlow,
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
|
||||
subscriptionStatusStateFlow,
|
||||
authDiskSource.activeUserIdChangesFlow
|
||||
.flatMapLatest { userId ->
|
||||
userId
|
||||
?.let { id ->
|
||||
settingsDiskSource
|
||||
.getPremiumUpgradeBannerDismissedFlow(id)
|
||||
.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 premiumCardStateFlow: StateFlow<PremiumCard> =
|
||||
combine(
|
||||
authDiskSource.userStateFlow.map { it?.activeAccount },
|
||||
billingRepository.isInAppBillingSupportedFlow,
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
|
||||
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
|
||||
userId
|
||||
?.let { id ->
|
||||
settingsDiskSource
|
||||
.getPremiumUpgradeBannerDismissedFlow(id)
|
||||
.map { it ?: false }
|
||||
}
|
||||
?: flowOf(false)
|
||||
},
|
||||
vaultRepository.vaultDataStateFlow,
|
||||
) {
|
||||
userState,
|
||||
account,
|
||||
isInAppBillingSupported,
|
||||
featureFlagEnabled,
|
||||
isDismissed,
|
||||
isUpgradeCardDismissed,
|
||||
vaultDataState,
|
||||
->
|
||||
BannerInputs(
|
||||
userState = userState,
|
||||
account = account,
|
||||
isInAppBillingSupported = isInAppBillingSupported,
|
||||
featureFlagEnabled = featureFlagEnabled,
|
||||
isDismissed = isDismissed,
|
||||
isUpgradeCardDismissed = isUpgradeCardDismissed,
|
||||
vaultDataState = vaultDataState,
|
||||
)
|
||||
}
|
||||
.combine(subscriptionStatusStateFlow) { inputs, subscriptionStatus ->
|
||||
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 hasPremium = profile.hasPremiumPersonally == true ||
|
||||
profile.hasPremiumFromOrganization == true
|
||||
val isEffectivelyPremium = hasPremium &&
|
||||
!subscriptionStatus.isInTroubleState()
|
||||
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
|
||||
val profile = inputs.account?.profile ?: return@combine PremiumCard.NONE
|
||||
if (!inputs.featureFlagEnabled) return@combine PremiumCard.NONE
|
||||
val initialCard = when (lifecycle) {
|
||||
UpgradeLifecycleState.Free -> PremiumCard.UPGRADE
|
||||
UpgradeLifecycleState.UpgradePending -> PremiumCard.NONE
|
||||
is UpgradeLifecycleState.Premium -> {
|
||||
lifecycle.subscriptionStatus.premiumCardState()
|
||||
}
|
||||
}
|
||||
when (initialCard) {
|
||||
PremiumCard.UPGRADE -> {
|
||||
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
|
||||
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
|
||||
clock = clock,
|
||||
)
|
||||
val itemCount = inputs.vaultDataState.activeVaultItemCount()
|
||||
val showCard = inputs.isInAppBillingSupported &&
|
||||
isAccountOldEnough &&
|
||||
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
|
||||
!inputs.isUpgradeCardDismissed
|
||||
initialCard.takeIf { showCard } ?: PremiumCard.NONE
|
||||
}
|
||||
|
||||
!isEffectivelyPremium &&
|
||||
inputs.isInAppBillingSupported &&
|
||||
inputs.featureFlagEnabled &&
|
||||
!inputs.isDismissed &&
|
||||
isAccountOldEnough &&
|
||||
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
|
||||
PremiumCard.NEEDS_ATTENTION,
|
||||
PremiumCard.NONE,
|
||||
-> initialCard
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
initialValue = PremiumCard.NONE,
|
||||
)
|
||||
|
||||
override val isSelfHostedFlow: StateFlow<Boolean> =
|
||||
@@ -254,6 +301,7 @@ class PremiumStateManagerImpl(
|
||||
// an upgrade.
|
||||
if (previous?.first == currentUserId && !previous.second) {
|
||||
markUpgradedToPremiumCardPending(userId = currentUserId)
|
||||
clearPremiumUpgradePending(userId = currentUserId)
|
||||
}
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
@@ -285,6 +333,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) {
|
||||
@@ -321,31 +398,41 @@ class PremiumStateManagerImpl(
|
||||
}
|
||||
|
||||
private data class BannerInputs(
|
||||
val userState: UserStateJson?,
|
||||
val account: AccountJson?,
|
||||
val isInAppBillingSupported: Boolean,
|
||||
val featureFlagEnabled: Boolean,
|
||||
val isDismissed: Boolean,
|
||||
val isUpgradeCardDismissed: 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.
|
||||
* Returns a [PremiumCard] for the given [SubscriptionStatusState] and subscription substate.
|
||||
*/
|
||||
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
|
||||
this is SubscriptionStatusState.Available &&
|
||||
when (this.status) {
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> true
|
||||
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
|
||||
when (this) {
|
||||
is SubscriptionStatusState.Available -> {
|
||||
when (this.status) {
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> PremiumCard.NEEDS_ATTENTION
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
-> false
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
-> PremiumCard.UPGRADE
|
||||
|
||||
PremiumSubscriptionStatus.ACTIVE,
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
-> PremiumCard.NONE
|
||||
}
|
||||
}
|
||||
|
||||
is SubscriptionStatusState.Error,
|
||||
SubscriptionStatusState.Loading,
|
||||
SubscriptionStatusState.NoSubscription,
|
||||
-> PremiumCard.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.billing.model
|
||||
|
||||
/**
|
||||
* Represents which premium card should be displayed.
|
||||
*/
|
||||
enum class PremiumCard {
|
||||
UPGRADE,
|
||||
NEEDS_ATTENTION,
|
||||
NONE,
|
||||
}
|
||||
@@ -7,6 +7,13 @@ enum class PremiumSubscriptionStatus {
|
||||
ACTIVE,
|
||||
CANCELED,
|
||||
|
||||
/**
|
||||
* The subscription's initial payment never succeeded and Stripe voided the invoice, so
|
||||
* the subscription never became active. Distinct from [CANCELED], which describes a
|
||||
* subscription that was previously active.
|
||||
*/
|
||||
EXPIRED,
|
||||
|
||||
/**
|
||||
* The subscription is scheduled to cancel at a future date but is still active until then.
|
||||
*/
|
||||
|
||||
@@ -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,28 +12,35 @@ 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
|
||||
|
||||
@@ -64,10 +72,9 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionStatusJson.CANCELED,
|
||||
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
|
||||
-> PremiumSubscriptionStatus.CANCELED
|
||||
SubscriptionStatusJson.CANCELED -> PremiumSubscriptionStatus.CANCELED
|
||||
|
||||
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> PremiumSubscriptionStatus.EXPIRED
|
||||
SubscriptionStatusJson.INCOMPLETE,
|
||||
SubscriptionStatusJson.UNPAID,
|
||||
-> PremiumSubscriptionStatus.UPDATE_PAYMENT
|
||||
@@ -77,16 +84,18 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,10 @@ interface EnvironmentDiskSource {
|
||||
* Stores the [urls] for the given [userEmail].
|
||||
*/
|
||||
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
|
||||
|
||||
/**
|
||||
* The fill-assist URL provided by the server config, or `null` if the server does not
|
||||
* configure fill-assist targeting rules.
|
||||
*/
|
||||
var fillAssistRulesUrl: String?
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
|
||||
|
||||
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
|
||||
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
|
||||
private const val FILL_ASSIST_RULES_URL_KEY = "fillAssistRulesUrl"
|
||||
|
||||
/**
|
||||
* Primary implementation of [EnvironmentDiskSource].
|
||||
@@ -54,4 +55,8 @@ class EnvironmentDiskSourceImpl(
|
||||
value = json.encodeToString(urls),
|
||||
)
|
||||
}
|
||||
|
||||
override var fillAssistRulesUrl: String?
|
||||
get() = getString(key = FILL_ASSIST_RULES_URL_KEY)
|
||||
set(value) = putString(key = FILL_ASSIST_RULES_URL_KEY, value = value)
|
||||
}
|
||||
|
||||
@@ -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?> =
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
|
||||
import com.bitwarden.network.model.BitwardenServiceClientConfig
|
||||
import com.bitwarden.network.service.ConfigService
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.bitwarden.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
|
||||
@@ -32,6 +33,12 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlatformNetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesFillAssistService(
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
): FillAssistService = bitwardenServiceClient.fillAssistService
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesConfigService(
|
||||
|
||||
@@ -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
|
||||
@@ -220,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,
|
||||
@@ -262,8 +265,12 @@ object PlatformManagerModule {
|
||||
@Singleton
|
||||
fun providePolicyManager(
|
||||
authDiskSource: AuthDiskSource,
|
||||
authSdkSource: AuthSdkSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
): PolicyManager = PolicyManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
authSdkSource = authSdkSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -6,8 +6,12 @@ import com.bitwarden.network.BitwardenServiceClient
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val ENVIRONMENT_DEBOUNCE_TIMEOUT_MS: Long = 500L
|
||||
@@ -26,9 +30,11 @@ class NetworkConfigManagerImpl(
|
||||
private val collectionScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
init {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
environmentRepository
|
||||
.environmentStateFlow
|
||||
@OptIn(FlowPreview::class)
|
||||
combine(
|
||||
environmentRepository.environmentStateFlow,
|
||||
authRepository.userStateFlow.map { it?.activeUserId }.distinctUntilChanged(),
|
||||
) { _, _ -> }
|
||||
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
|
||||
.onEach { _ ->
|
||||
// This updates the stored service configuration by performing a network request.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -31,4 +31,6 @@ class BaseUrlsProviderImpl(
|
||||
.toEnvironmentUrlsOrDefault()
|
||||
.environmentUrlData
|
||||
.baseEventsUrl
|
||||
|
||||
override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
|
||||
@@ -135,20 +134,17 @@ class AuthenticatorBridgeRepositoryImpl(
|
||||
account: AccountJson,
|
||||
decryptedUserKey: String,
|
||||
): VaultUnlockResult {
|
||||
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
|
||||
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
|
||||
?: authDiskSource.getPrivateKey(userId = userId)
|
||||
val accountCryptographicState = authDiskSource
|
||||
.getAccountCryptographicState(userId = userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(
|
||||
MissingPropertyException("Private key"),
|
||||
error = MissingPropertyException("Account Cryptographic State"),
|
||||
)
|
||||
|
||||
return scopedVaultSdkSource
|
||||
.initializeCrypto(
|
||||
userId = userId,
|
||||
request = InitUserCryptoRequest(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
),
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
userId = userId,
|
||||
kdfParams = account.profile.toSdkParams(),
|
||||
email = account.profile.email,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.x8bit.bitwarden.data.platform.repository
|
||||
|
||||
import android.view.autofill.AutofillManager
|
||||
import com.bitwarden.authenticatorbridge.util.generateSecretKey
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
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
|
||||
@@ -51,6 +52,7 @@ class SettingsRepositoryImpl(
|
||||
private val autofillManager: AutofillManager,
|
||||
private val autofillEnabledManager: AutofillEnabledManager,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val buildInfoManager: BuildInfoManager,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
flightRecorderManager: FlightRecorderManager,
|
||||
@@ -372,13 +374,28 @@ class SettingsRepositoryImpl(
|
||||
initialValue = isScreenCaptureAllowed,
|
||||
)
|
||||
|
||||
override val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
|
||||
get() = settingsDiskSource
|
||||
.hasShownAccessibilityDisclaimerFlow
|
||||
.map { buildInfoManager.isFdroid || it ?: false }
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Lazily,
|
||||
initialValue = buildInfoManager.isFdroid ||
|
||||
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 +693,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,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.platform.repository.di
|
||||
|
||||
import android.view.autofill.AutofillManager
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
@@ -67,6 +68,7 @@ object PlatformRepositoryModule {
|
||||
autofillManager: AutofillManager,
|
||||
autofillEnabledManager: AutofillEnabledManager,
|
||||
authDiskSource: AuthDiskSource,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
accessibilityEnabledManager: AccessibilityEnabledManager,
|
||||
@@ -78,6 +80,7 @@ object PlatformRepositoryModule {
|
||||
autofillManager = autofillManager,
|
||||
autofillEnabledManager = autofillEnabledManager,
|
||||
authDiskSource = authDiskSource,
|
||||
buildInfoManager = buildInfoManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
accessibilityEnabledManager = accessibilityEnabledManager,
|
||||
|
||||
@@ -23,23 +23,23 @@ private fun EnvironmentUrlDataJson.authTabData(
|
||||
kind: String,
|
||||
): AuthTabData = when (this.environmentRegion) {
|
||||
EnvironmentRegion.UNITED_STATES -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
AuthTabData.HttpsScheme(
|
||||
host = "bitwarden.com",
|
||||
path = "$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentRegion.EUROPEAN_UNION -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
AuthTabData.HttpsScheme(
|
||||
host = "bitwarden.eu",
|
||||
path = "$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentRegion.INTERNAL -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
AuthTabData.HttpsScheme(
|
||||
host = "bitwarden.pw",
|
||||
path = "$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +54,7 @@ class GeneratorRepositoryImpl(
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val passwordHistoryDiskSource: PasswordHistoryDiskSource,
|
||||
private val reviewPromptManager: ReviewPromptManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : GeneratorRepository {
|
||||
|
||||
private val scope = CoroutineScope(dispatcherManager.io)
|
||||
@@ -193,8 +192,9 @@ class GeneratorRepositoryImpl(
|
||||
|
||||
override suspend fun generateForwardedServiceUsername(
|
||||
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
|
||||
): GeneratedForwardedServiceUsernameResult = withContext(dispatcherManager.io) {
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.decodeFromStringWithErrorCallback
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.ui.platform.base.util.orNullIfBlank
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
|
||||
@@ -134,7 +135,7 @@ class VaultDiskSourceImpl(
|
||||
.filter {
|
||||
// A safety-check since after the DB migration, we will temporarily think
|
||||
// all ciphers contain a totp code
|
||||
it.login?.totp != null
|
||||
it.login?.totp.orNullIfBlank() != null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +167,7 @@ class VaultDiskSourceImpl(
|
||||
CipherEntity(
|
||||
id = cipher.id,
|
||||
userId = userId,
|
||||
hasTotp = cipher.login?.totp != null,
|
||||
hasTotp = cipher.login?.totp.orNullIfBlank() != null,
|
||||
cipherType = json.encodeToString(cipher.type),
|
||||
cipherJson = json.encodeToString(cipher),
|
||||
organizationId = cipher.organizationId,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -190,9 +190,9 @@ class VaultSdkSourceImpl(
|
||||
): Result<InitializeCryptoResult> =
|
||||
runCatchingWithLogs {
|
||||
try {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.initializeUserCrypto(req = request)
|
||||
withContext(context = dispatcherManager.io) {
|
||||
getClient(userId = userId).crypto().initializeUserCrypto(req = request)
|
||||
}
|
||||
InitializeCryptoResult.Success
|
||||
} catch (exception: BitwardenException) {
|
||||
// The only truly expected error from the SDK is an incorrect key/password.
|
||||
|
||||
@@ -130,7 +130,6 @@ interface CipherManager {
|
||||
*/
|
||||
suspend fun updateCipherCollections(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
collectionIds: List<String>,
|
||||
): ShareCipherResult
|
||||
|
||||
|
||||
@@ -442,7 +442,6 @@ class CipherManagerImpl(
|
||||
|
||||
override suspend fun updateCipherCollections(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
collectionIds: List<String>,
|
||||
): ShareCipherResult {
|
||||
val userId = activeUserId ?: return ShareCipherResult.Error(error = NoActiveUserException())
|
||||
@@ -451,17 +450,18 @@ class CipherManagerImpl(
|
||||
cipherId = cipherId,
|
||||
body = UpdateCipherCollectionsJsonRequest(collectionIds = collectionIds),
|
||||
)
|
||||
.flatMap {
|
||||
vaultSdkSource.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = cipherView.copy(collectionIds = collectionIds),
|
||||
)
|
||||
}
|
||||
.onSuccess { encryptionContext ->
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = encryptionContext.toEncryptedNetworkCipherResponse(),
|
||||
)
|
||||
.onSuccess { response ->
|
||||
response
|
||||
.cipher
|
||||
?.let {
|
||||
// Save the updated cipher to disk.
|
||||
vaultDiskSource.saveCipher(userId = userId, cipher = it)
|
||||
}
|
||||
?: run {
|
||||
// The user no longer has any collection access to the cipher after
|
||||
// the update, so remove it from disk.
|
||||
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { ShareCipherResult.Success },
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
|
||||
@@ -675,17 +674,14 @@ class VaultLockManagerImpl(
|
||||
): VaultUnlockResult {
|
||||
val account = authDiskSource.userState?.accounts?.get(userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
|
||||
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
|
||||
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
|
||||
?: authDiskSource.getPrivateKey(userId = userId)
|
||||
val accountCryptographicState = authDiskSource
|
||||
.getAccountCryptographicState(userId = userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(
|
||||
error = MissingPropertyException("Private key"),
|
||||
error = MissingPropertyException("Account Cryptographic State"),
|
||||
)
|
||||
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
|
||||
return unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
),
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
kdf = account.profile.toSdkParams(),
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
@@ -376,9 +378,14 @@ class VaultSyncManagerImpl(
|
||||
val profile = syncResponse.profile
|
||||
val userId = profile.id
|
||||
authDiskSource.apply {
|
||||
storeUserKey(userId = userId, userKey = profile.key)
|
||||
storePrivateKey(userId = userId, privateKey = profile.privateKey)
|
||||
storeAccountKeys(userId = userId, accountKeys = profile.accountKeys)
|
||||
storeAccountCryptographicState(
|
||||
userId = userId,
|
||||
accountCryptographicState = profile.privateKeyOrNull()?.let {
|
||||
profile.accountKeys.toAccountCryptographicState(
|
||||
privateKey = it,
|
||||
)
|
||||
},
|
||||
)
|
||||
storeOrganizationKeys(
|
||||
userId = userId,
|
||||
organizationKeys = profile.organizations
|
||||
@@ -469,6 +476,9 @@ class VaultSyncManagerImpl(
|
||||
data = collections.sortAlphabeticallyByTypeAndOrganization(
|
||||
userOrganizations = authDiskSource
|
||||
.getOrganizations(userId = userId)
|
||||
?.filter { org ->
|
||||
org.status == OrganizationStatusType.CONFIRMED
|
||||
}
|
||||
.orEmpty(),
|
||||
),
|
||||
)
|
||||
@@ -536,6 +546,13 @@ class VaultSyncManagerImpl(
|
||||
?: DataState.Loading
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to extract the private key from the [SyncResponseJson.Profile] response.
|
||||
*/
|
||||
private fun SyncResponseJson.Profile.privateKeyOrNull(): String? =
|
||||
this.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
|
||||
?: this.privateKey
|
||||
|
||||
private fun <T> Throwable.toNetworkOrErrorState(
|
||||
data: T?,
|
||||
): DataState<T> =
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.bitwarden.vault.CipherType
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.autofill.util.login
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
@@ -42,6 +41,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
|
||||
@@ -322,21 +322,19 @@ class VaultRepositoryImpl(
|
||||
decryptedUserKey = decryptedUserKey,
|
||||
),
|
||||
)
|
||||
.also {
|
||||
if (it is VaultUnlockResult.Success) {
|
||||
encryptedBiometricsKey?.let { key ->
|
||||
// If this key is present, we store it and the associated IV for future use
|
||||
// since we want to migrate the user to a more secure form of biometrics.
|
||||
authDiskSource.storeUserBiometricUnlockKey(
|
||||
userId = userId,
|
||||
biometricsKey = key,
|
||||
)
|
||||
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
|
||||
}
|
||||
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
|
||||
.onVaultUnlockSuccess {
|
||||
encryptedBiometricsKey?.let { key ->
|
||||
// If this key is present, we store it and the associated IV for future use
|
||||
// since we want to migrate the user to a more secure form of biometrics.
|
||||
authDiskSource.storeUserBiometricUnlockKey(
|
||||
userId = userId,
|
||||
biometricsKey = key,
|
||||
)
|
||||
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
|
||||
}
|
||||
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +361,10 @@ class VaultRepositoryImpl(
|
||||
userId = userId,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
)
|
||||
.also {
|
||||
if (it is VaultUnlockResult.Success) {
|
||||
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
.onVaultUnlockSuccess {
|
||||
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
|
||||
userId = userId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,19 +555,14 @@ class VaultRepositoryImpl(
|
||||
): VaultUnlockResult {
|
||||
val account = authDiskSource.userState?.accounts?.get(userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
|
||||
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
|
||||
val privateKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.wrappedPrivateKey
|
||||
?: authDiskSource.getPrivateKey(userId = userId)
|
||||
val accountCryptographicState = authDiskSource
|
||||
.getAccountCryptographicState(userId = userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(
|
||||
error = MissingPropertyException("Private key"),
|
||||
error = MissingPropertyException("Account Cryptographic State"),
|
||||
)
|
||||
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
|
||||
return vaultLockManager.unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
),
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
kdf = account.profile.toSdkParams(),
|
||||
|
||||
@@ -46,3 +46,18 @@ sealed class VaultUnlockResult {
|
||||
sealed interface VaultUnlockError {
|
||||
val error: Throwable?
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the [onSuccess] lambda as a side effect.
|
||||
*/
|
||||
inline fun VaultUnlockResult.onVaultUnlockSuccess(
|
||||
onSuccess: () -> Unit,
|
||||
): VaultUnlockResult = when (this) {
|
||||
is VaultUnlockResult.AuthenticationError,
|
||||
is VaultUnlockResult.BiometricDecodingError,
|
||||
is VaultUnlockResult.GenericError,
|
||||
is VaultUnlockResult.InvalidStateError,
|
||||
-> this
|
||||
|
||||
is VaultUnlockResult.Success -> this.also { onSuccess() }
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ fun Cipher.toEncryptedNetworkCipher(
|
||||
key = key,
|
||||
sshKey = sshKey?.toEncryptedNetworkSshKey(),
|
||||
bankAccount = bankAccount?.toEncryptedNetworkBankAccount(),
|
||||
driversLicense = null,
|
||||
passport = null,
|
||||
driversLicense = driversLicense?.toEncryptedNetworkDriversLicense(),
|
||||
passport = passport?.toEncryptedNetworkPassport(),
|
||||
archivedDate = archivedDate,
|
||||
encryptedFor = encryptedFor,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.util
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
|
||||
/**
|
||||
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
|
||||
*
|
||||
* Returns [WrappedAccountCryptographicState.V2] if signing key, signed public key, and security
|
||||
* state are all present, otherwise returns [WrappedAccountCryptographicState.V1].
|
||||
*
|
||||
* @param privateKey The user's wrapped private key.
|
||||
* @param securityState The user's signed security state (V2 only).
|
||||
* @param signingKey The user's wrapped signing key (V2 only).
|
||||
* @param signedPublicKey The user's signed public key (V2 only).
|
||||
*/
|
||||
fun createWrappedAccountCryptographicState(
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signingKey: String?,
|
||||
signedPublicKey: String?,
|
||||
): WrappedAccountCryptographicState {
|
||||
return if (signingKey != null && securityState != null && signedPublicKey != null) {
|
||||
WrappedAccountCryptographicState.V2(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
)
|
||||
} else {
|
||||
WrappedAccountCryptographicState.V1(
|
||||
privateKey = privateKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.removepassword
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -14,14 +17,17 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
@@ -30,8 +36,11 @@ import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
||||
import com.bitwarden.ui.platform.components.icon.BitwardenIcon
|
||||
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.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.util.asText
|
||||
@@ -91,53 +100,41 @@ private fun RemovePasswordScreenContent(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
Text(
|
||||
text = state.description(),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
Text(
|
||||
text = state.labelOrg(),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
RemovePasswordInfoColumn(
|
||||
label = state.labelOrg(),
|
||||
value = state.orgName?.invoke().orEmpty(),
|
||||
icon = IconData.Local(iconRes = BitwardenDrawable.ic_organization),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
|
||||
paddingBottom = 4.dp,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = state.orgName?.invoke().orEmpty(),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
RemovePasswordInfoColumn(
|
||||
label = state.labelDomain(),
|
||||
value = state.domainName?.invoke().orEmpty(),
|
||||
icon = IconData.Local(iconRes = BitwardenDrawable.ic_globe),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
.cardStyle(cardStyle = CardStyle.Bottom),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
Text(
|
||||
text = state.labelDomain(),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = state.domainName?.invoke().orEmpty(),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = BitwardenString.master_password),
|
||||
@@ -179,6 +176,43 @@ private fun RemovePasswordScreenContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemovePasswordInfoColumn(
|
||||
label: String,
|
||||
value: String,
|
||||
icon: IconData,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.defaultMinSize(minHeight = 60.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(width = 12.dp))
|
||||
BitwardenIcon(
|
||||
iconData = icon,
|
||||
tint = BitwardenTheme.colorScheme.icon.secondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(width = 12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = BitwardenTheme.typography.titleSmall,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 4.dp))
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemovePasswordDialogs(
|
||||
dialogState: RemovePasswordState.DialogState?,
|
||||
|
||||
@@ -32,12 +32,14 @@ class RemovePasswordViewModel @Inject constructor(
|
||||
val org = authRepository.userStateFlow.value
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.firstOrNull { it.shouldUseKeyConnector }
|
||||
?.firstOrNull { it.isKeyConnectorEnabled }
|
||||
|
||||
RemovePasswordState(
|
||||
input = "",
|
||||
description = BitwardenString.password_no_longer_required_confirm_domain.asText(),
|
||||
labelOrg = BitwardenString.key_connector_organization.asText(),
|
||||
description = BitwardenString
|
||||
.your_organization_no_longer_requires_a_master_password
|
||||
.asText(),
|
||||
labelOrg = BitwardenString.organization.asText(),
|
||||
orgName = org?.name?.asText(),
|
||||
labelDomain = BitwardenString.key_connector_domain.asText(),
|
||||
domainName = org?.keyConnectorUrl?.asText(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,7 @@ fun NavGraphBuilder.debugMenuDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<DebugRoute> {
|
||||
composableWithSlideTransitions<DebugRoute> {
|
||||
DebugMenuScreen(onNavigateBack = onNavigateBack)
|
||||
// If we are displaying the debug screen, then we can just hide the splash screen.
|
||||
onSplashScreenRemoved()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the overlay navigation screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object OverlayNavRoute
|
||||
|
||||
/**
|
||||
* Add the overlay navigation screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.overlayNavDestination(
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
composable<OverlayNavRoute> {
|
||||
OverlayNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
|
||||
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.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
|
||||
|
||||
/**
|
||||
* Controls the overlay [NavHost] for the app including the [rootNavDestination] and any screen
|
||||
* that can appear on top of it without affecting its state.
|
||||
*/
|
||||
@Composable
|
||||
fun OverlayNavScreen(
|
||||
viewModel: OverlayNavViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberBitwardenNavController(name = "OverlayNavScreen"),
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
OverlayNavEventsEffect(
|
||||
viewModel = viewModel,
|
||||
navController = navController,
|
||||
)
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
) {
|
||||
// This is the overlay level of navigation that sits above the root nav. These screens
|
||||
// can appear on top of the rest of the app without interacting with the state-based
|
||||
// navigation used by RootNavScreen (which also exists here).
|
||||
rootNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
|
||||
cookieAcquisitionDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = onSplashScreenRemoved,
|
||||
)
|
||||
localNetworkAccessDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = onSplashScreenRemoved,
|
||||
)
|
||||
accessibilityDisclosureDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = onSplashScreenRemoved,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayNavEventsEffect(
|
||||
viewModel: OverlayNavViewModel,
|
||||
navController: NavController,
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
OverlayNavEvent.NavigateToCookieAcquisition -> {
|
||||
navController.navigateToCookieAcquisition()
|
||||
}
|
||||
|
||||
OverlayNavEvent.NavigateToLocalNetworkAccess -> {
|
||||
navController.navigateToLocalNetworkAccess()
|
||||
}
|
||||
|
||||
OverlayNavEvent.NavigateToAccessibilityDisclosure -> {
|
||||
navController.navigateToAccessibilityDisclosure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manages the overlay navigation, hosting the root-navigation and any screen that can overlay it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OverlayNavViewModel @Inject constructor(
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
networkPermissionManager: NetworkPermissionManager,
|
||||
settingsRepository: SettingsRepository,
|
||||
) : BaseViewModel<Unit, OverlayNavEvent, OverlayNavAction>(initialState = Unit) {
|
||||
init {
|
||||
settingsRepository
|
||||
.hasShownAccessibilityDisclaimerFlow
|
||||
.filterNot { it }
|
||||
.map { OverlayNavAction.Internal.AccessibilityDisclosureRequired }
|
||||
.onEach(::trySendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
networkPermissionManager
|
||||
.isLocalNetworkAccessRequiredStateFlow
|
||||
.filter { it }
|
||||
.map { OverlayNavAction.Internal.LocalNetworkAccessRequired }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
cookieAcquisitionRequestManager
|
||||
.cookieAcquisitionRequestFlow
|
||||
.filterNotNull()
|
||||
.map { OverlayNavAction.Internal.CookieAcquisitionReady }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: OverlayNavAction) {
|
||||
when (action) {
|
||||
is OverlayNavAction.Internal -> handleInternal(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternal(action: OverlayNavAction.Internal) {
|
||||
when (action) {
|
||||
OverlayNavAction.Internal.AccessibilityDisclosureRequired -> {
|
||||
handleAccessibilityDisclosureRequired()
|
||||
}
|
||||
|
||||
OverlayNavAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
|
||||
OverlayNavAction.Internal.LocalNetworkAccessRequired -> {
|
||||
handleLocalNetworkAccessRequired()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAccessibilityDisclosureRequired() {
|
||||
sendEvent(OverlayNavEvent.NavigateToAccessibilityDisclosure)
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionReady() {
|
||||
sendEvent(OverlayNavEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleLocalNetworkAccessRequired() {
|
||||
sendEvent(OverlayNavEvent.NavigateToLocalNetworkAccess)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the overlay navigation screen.
|
||||
*/
|
||||
sealed class OverlayNavEvent {
|
||||
/**
|
||||
* Navigate to the cookie acquisition screen.
|
||||
*/
|
||||
data object NavigateToCookieAcquisition : OverlayNavEvent(), DeferredBackgroundEvent
|
||||
|
||||
/**
|
||||
* Navigate to the local network access screen.
|
||||
*/
|
||||
data object NavigateToLocalNetworkAccess : OverlayNavEvent(), DeferredBackgroundEvent
|
||||
|
||||
/**
|
||||
* Navigate to the accessibility disclosure screen.
|
||||
*/
|
||||
data object NavigateToAccessibilityDisclosure : OverlayNavEvent(), DeferredBackgroundEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the overlay navigation screen.
|
||||
*/
|
||||
sealed class OverlayNavAction {
|
||||
/**
|
||||
* Internal ViewModel actions.
|
||||
*/
|
||||
sealed class Internal : OverlayNavAction() {
|
||||
|
||||
/**
|
||||
* Indicates that the cookie acquisition conditions are met and navigation
|
||||
* should proceed.
|
||||
*/
|
||||
data object CookieAcquisitionReady : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the local network access is required.
|
||||
*/
|
||||
data object LocalNetworkAccessRequired : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the accessibility disclosure needs to be displayed.
|
||||
*/
|
||||
data object AccessibilityDisclosureRequired : Internal()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -72,6 +73,7 @@ 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 = "--"
|
||||
@@ -280,7 +282,6 @@ private fun FreeCloudContent(
|
||||
handlers: PlanHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowUpgradeDialog by rememberSaveable { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -295,53 +296,48 @@ private fun FreeCloudContent(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.upgrade_now),
|
||||
onClick = { shouldShowUpgradeDialog = true },
|
||||
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))
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowUpgradeDialog) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.continue_to_stripe),
|
||||
message = stringResource(
|
||||
id = BitwardenString
|
||||
.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
|
||||
),
|
||||
confirmButtonText = stringResource(id = BitwardenString.continue_text),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
onConfirmClick = {
|
||||
shouldShowUpgradeDialog = false
|
||||
handlers.onUpgradeNowClick()
|
||||
},
|
||||
onDismissClick = { shouldShowUpgradeDialog = false },
|
||||
onDismissRequest = { shouldShowUpgradeDialog = false },
|
||||
)
|
||||
}
|
||||
@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")
|
||||
@@ -399,25 +395,30 @@ private fun PremiumFeaturesCard(
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenHorizontalDivider()
|
||||
PremiumFeatureRows()
|
||||
}
|
||||
}
|
||||
|
||||
val features = listOf(
|
||||
BitwardenString.built_in_authenticator,
|
||||
BitwardenString.emergency_access,
|
||||
BitwardenString.secure_file_storage,
|
||||
BitwardenString.breach_monitoring,
|
||||
@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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,21 +569,18 @@ private fun PremiumContent(
|
||||
}
|
||||
}
|
||||
|
||||
@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.
|
||||
// Override bottom padding; the final row (line item or feature) owns its
|
||||
// own spacing.
|
||||
paddingBottom = 0.dp,
|
||||
),
|
||||
) {
|
||||
@@ -599,55 +597,70 @@ private fun SubscriptionCard(
|
||||
.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,
|
||||
)
|
||||
if (viewState.status?.showsFeatureList() == true) {
|
||||
PremiumFeatureRows()
|
||||
} else {
|
||||
SubscriptionLineItems(viewState = viewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
@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.estimated_tax),
|
||||
value = viewState.estimatedTaxText,
|
||||
testTag = "EstimatedTaxRow",
|
||||
label = stringResource(id = BitwardenString.storage_cost),
|
||||
value = storageCostText,
|
||||
testTag = "StorageCostRow",
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -698,7 +711,7 @@ private fun SubscriptionHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Composable
|
||||
private fun subscriptionDescriptionText(
|
||||
status: PremiumSubscriptionStatus?,
|
||||
@@ -765,6 +778,13 @@ private fun subscriptionDescriptionText(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -811,6 +831,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
|
||||
rate = "$1.67",
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = false,
|
||||
),
|
||||
handlers = PlanHandlers(
|
||||
onBackClick = {},
|
||||
|
||||
@@ -19,18 +19,22 @@ 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
|
||||
@@ -39,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
|
||||
@@ -91,6 +94,9 @@ class PlanViewModel @Inject constructor(
|
||||
rate = PLACEHOLDER_TEXT,
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.value is UpgradeLifecycleState.UpgradePending,
|
||||
)
|
||||
},
|
||||
dialogState = null,
|
||||
@@ -124,6 +130,12 @@ class PlanViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onFreeCloudContent {
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
@@ -187,6 +199,10 @@ class PlanViewModel @Inject constructor(
|
||||
is PlanAction.Internal.SubscriptionStatusUpdateReceive -> {
|
||||
handleSubscriptionStatusUpdateReceive(action)
|
||||
}
|
||||
|
||||
is PlanAction.Internal.UpgradeLifecycleStateReceive -> {
|
||||
handleUpgradeLifecycleStateReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +419,9 @@ class PlanViewModel @Inject constructor(
|
||||
rate = PLACEHOLDER_TEXT,
|
||||
checkoutUrl = null,
|
||||
isAwaitingPremiumStatus = false,
|
||||
isPremiumUpgradePending = premiumStateManager
|
||||
.upgradeLifecycleStateFlow
|
||||
.value is UpgradeLifecycleState.UpgradePending,
|
||||
),
|
||||
dialogState = PlanState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
@@ -433,6 +452,22 @@ 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,
|
||||
) {
|
||||
@@ -561,14 +596,19 @@ class PlanViewModel @Inject constructor(
|
||||
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,
|
||||
@@ -675,11 +715,11 @@ class PlanViewModel @Inject constructor(
|
||||
|
||||
return PlanState.ViewState.Premium(
|
||||
status = status,
|
||||
billingAmountText = seatsCost.toBillingAmountText(cadence),
|
||||
storageCostText = storageCost.toOptionalMoneyText(),
|
||||
discountAmountText = discountAmount.toOptionalMoneyText(negative = true),
|
||||
estimatedTaxText = estimatedTax.toRequiredMoneyText(),
|
||||
totalText = nextChargeTotal.toBillingAmountText(cadence),
|
||||
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,
|
||||
cancelAtDateText = formattedCancelAt,
|
||||
@@ -690,36 +730,6 @@ class PlanViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): 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`, matching the Web convention of always rendering
|
||||
* the Estimated Tax and Total rows.
|
||||
*/
|
||||
private fun BigDecimal?.toRequiredMoneyText(): String =
|
||||
currencyFormatter.format(this ?: BigDecimal.ZERO)
|
||||
|
||||
/**
|
||||
* Formats this amount for a hide-when-absent line item. Returns `null` when the amount is
|
||||
* `null` or non-positive so the caller can omit the row entirely (Discount, Storage).
|
||||
* When [negative] is true, the formatted value is prefixed with `-` to match the canonical
|
||||
* Web discount styling.
|
||||
*/
|
||||
private fun BigDecimal?.toOptionalMoneyText(negative: Boolean = false): String? =
|
||||
when {
|
||||
this == null || this.signum() <= 0 -> null
|
||||
negative -> "-${currencyFormatter.format(this)}"
|
||||
else -> currencyFormatter.format(this)
|
||||
}
|
||||
|
||||
private fun Instant.toLocalizedDate(): String =
|
||||
toFormattedDateStyle(
|
||||
dateStyle = FormatStyle.LONG,
|
||||
@@ -802,6 +812,7 @@ data class PlanState(
|
||||
val rate: String,
|
||||
val checkoutUrl: String?,
|
||||
val isAwaitingPremiumStatus: Boolean,
|
||||
val isPremiumUpgradePending: Boolean,
|
||||
) : Free()
|
||||
|
||||
/**
|
||||
@@ -1119,23 +1130,32 @@ sealed class PlanAction {
|
||||
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 or recoverable subscription. Terminal
|
||||
* states (canceled) do not present a cancel action.
|
||||
* 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,
|
||||
PremiumSubscriptionStatus.UPDATE_PAYMENT,
|
||||
-> true
|
||||
}
|
||||
|
||||
@@ -1147,6 +1167,7 @@ private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
|
||||
*/
|
||||
private fun PremiumSubscriptionStatus.isPremiumViewEligible(): Boolean = when (this) {
|
||||
PremiumSubscriptionStatus.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
|
||||
@@ -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,6 +14,7 @@ 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.EXPIRED -> BitwardenString.subscription_status_expired
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION -> {
|
||||
BitwardenString.subscription_status_pending_cancellation
|
||||
}
|
||||
@@ -23,6 +24,24 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [BitwardenColorScheme.StatusBadgeVariantColors] used to render the badge for a
|
||||
* [PremiumSubscriptionStatus].
|
||||
@@ -31,7 +50,10 @@ 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.CANCELED,
|
||||
PremiumSubscriptionStatus.EXPIRED,
|
||||
-> BitwardenTheme.colorScheme.statusBadge.error
|
||||
|
||||
PremiumSubscriptionStatus.PAST_DUE,
|
||||
PremiumSubscriptionStatus.PAUSED,
|
||||
PremiumSubscriptionStatus.PENDING_CANCELLATION,
|
||||
|
||||
@@ -268,7 +268,7 @@ class RootNavViewModel @Inject constructor(
|
||||
?.let(::parseJwtTokenDataOrNull)
|
||||
?.isExternal == true
|
||||
val usesKeyConnectorAndNotAdmin = this.activeAccount.organizations.any {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.isKeyConnectorEnabled &&
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ private fun SearchDialogs(
|
||||
when (dialogState) {
|
||||
SearchState.DialogState.ArchiveRequiresPremium -> {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.archive_unavailable),
|
||||
title = stringResource(id = BitwardenString.premium_subscription_required),
|
||||
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
|
||||
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.data.repository.util.baseIconUrl
|
||||
import com.bitwarden.data.repository.util.baseWebSendUrl
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
@@ -127,7 +127,7 @@ class SearchViewModel @Inject constructor(
|
||||
is SearchType.Sends -> null
|
||||
is SearchType.Vault -> userState.activeAccount.toVaultFilterData(
|
||||
isIndividualVaultDisabled = policyManager
|
||||
.getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.getActivePolicies(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
|
||||
.any(),
|
||||
)
|
||||
},
|
||||
@@ -156,7 +156,7 @@ class SearchViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.getActivePoliciesFlow(type = PolicyType.RESTRICTED_ITEM_TYPES)
|
||||
.map { policies -> policies.map { it.organizationId } }
|
||||
.map { SearchAction.Internal.RestrictItemTypesPolicyUpdateReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
|
||||
@@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +26,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
@@ -37,7 +37,6 @@ import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.account.dialog.BitwardenLogoutConfirmationDialog
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.badge.NotificationBadge
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
|
||||
import com.bitwarden.ui.platform.components.card.actionCardExitAnimation
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
@@ -635,50 +634,23 @@ private fun FingerPrintPhraseDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onLearnMore: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.close),
|
||||
onClick = onDismissRequest,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.learn_more),
|
||||
isExternalLink = true,
|
||||
onClick = onLearnMore,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.fingerprint_phrase),
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
style = BitwardenTheme.typography.headlineSmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "${stringResource(id = BitwardenString.your_accounts_fingerprint)}:",
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = fingerprintPhrase(),
|
||||
color = BitwardenTheme.colorScheme.text.codePink,
|
||||
style = BitwardenTheme.typography.sensitiveInfoSmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.fingerprint_phrase),
|
||||
message = buildAnnotatedString {
|
||||
append("${stringResource(id = BitwardenString.your_accounts_fingerprint)}:\n\n")
|
||||
withStyle(
|
||||
style = BitwardenTheme.typography.sensitiveInfoSmall
|
||||
.toSpanStyle()
|
||||
.copy(color = BitwardenTheme.colorScheme.text.codePink),
|
||||
) {
|
||||
append(fingerprintPhrase())
|
||||
}
|
||||
},
|
||||
containerColor = BitwardenTheme.colorScheme.background.primary,
|
||||
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
|
||||
titleContentColor = BitwardenTheme.colorScheme.text.primary,
|
||||
textContentColor = BitwardenTheme.colorScheme.text.primary,
|
||||
confirmButtonText = stringResource(id = BitwardenString.learn_more),
|
||||
dismissButtonText = stringResource(id = BitwardenString.close),
|
||||
onConfirmClick = onLearnMore,
|
||||
onDismissClick = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenPlurals
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
@@ -94,7 +94,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
|
||||
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
|
||||
.map { policies ->
|
||||
AccountSecurityAction.Internal.PolicyUpdateReceive(
|
||||
vaultTimeoutPolicies = policies.mapNotNull {
|
||||
@@ -106,7 +106,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN)
|
||||
.getActivePoliciesFlow(type = PolicyType.REMOVE_UNLOCK_WITH_PIN)
|
||||
.map { policies ->
|
||||
AccountSecurityAction.Internal.RemovePinPolicyUpdateReceive(
|
||||
removeUnlockWithPinPolicyEnabled = policies.isNotEmpty(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
@@ -78,7 +77,7 @@ fun PinInputDialog(
|
||||
// This background is necessary for the dialog to not be transparent.
|
||||
.background(
|
||||
color = BitwardenTheme.colorScheme.background.primary,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
shape = BitwardenTheme.shapes.dialog,
|
||||
),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,7 +56,7 @@ fun AddEditBlockedUriDialog(
|
||||
// This background is necessary for the dialog to not be transparent.
|
||||
.background(
|
||||
color = BitwardenTheme.colorScheme.background.primary,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
shape = BitwardenTheme.shapes.dialog,
|
||||
),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
|
||||
@@ -255,7 +255,7 @@ private fun PrivilegedAppsListContent(
|
||||
) {
|
||||
BitwardenStandardIconButton(
|
||||
vectorIconRes = BitwardenDrawable.ic_delete,
|
||||
contentDescription = "",
|
||||
contentDescription = stringResource(id = BitwardenString.delete),
|
||||
onClick = remember(item) {
|
||||
{ onDeleteClick(item) }
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.util.toFormattedPattern
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
@@ -66,7 +66,7 @@ class ExportVaultViewModel @Inject constructor(
|
||||
passwordInput = "",
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
policyPreventsExport = policyManager
|
||||
.getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT)
|
||||
.getActivePolicies(type = PolicyType.DISABLE_PERSONAL_VAULT_EXPORT)
|
||||
.any(),
|
||||
showSendCodeButton = authRepository
|
||||
.userStateFlow
|
||||
|
||||
@@ -140,6 +140,7 @@ private fun FlightRecorderContent(
|
||||
selectedOption = state.selectedDuration,
|
||||
onOptionSelected = onDurationSelected,
|
||||
modifier = Modifier
|
||||
.testTag("LoggingDurationChooser")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.vault
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.policies.PolicyType
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
@@ -54,7 +54,7 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.getActivePoliciesFlow(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
|
||||
.map { policies ->
|
||||
VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged(
|
||||
isEnabled = !buildInfoManager.isFdroid && policies.isEmpty(),
|
||||
@@ -133,7 +133,7 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
|
||||
private fun handleImportItemsClicked() {
|
||||
if (!buildInfoManager.isFdroid &&
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).isEmpty()
|
||||
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).isEmpty()
|
||||
) {
|
||||
sendEvent(VaultSettingsEvent.NavigateToImportItems)
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user