mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
Merge remote-tracking branch 'bwa-android/main' into bwa-monorepo
# Conflicts: # .checkmarx/config.yml # .github/CODEOWNERS # .github/ISSUE_TEMPLATE/bug.yml # .github/ISSUE_TEMPLATE/config.yml # .github/renovate.json # .github/workflows/build-authenticator.yml # .github/workflows/crowdin-pull-authenticator.yml # .github/workflows/crowdin-push-authenticator.yml # .github/workflows/scan-authenticator.yml # .github/workflows/test-authenticator.yml # .gitignore # Gemfile # Gemfile.lock # README.md # build.gradle.kts # fastlane/Fastfile # gradle.properties # gradle/libs.versions.toml # gradle/wrapper/gradle-wrapper.properties # gradlew.bat # settings.gradle.kts
This commit is contained in:
commit
dca7284230
2
.github/codecov.yml
vendored
Normal file
2
.github/codecov.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
ignore:
|
||||
- "src/test/**" # Tests
|
||||
284
.github/workflows/build-authenticator.yml
vendored
284
.github/workflows/build-authenticator.yml
vendored
@ -1,13 +1,289 @@
|
||||
name: Build Authenticator
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project."
|
||||
required: false
|
||||
type: string
|
||||
version-code:
|
||||
description: "Optional. Build number to use. Overrides default of GitHub run number."
|
||||
required: false
|
||||
type: number
|
||||
distribute-to-firebase:
|
||||
description: "Optional. Distribute artifacts to Firebase."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
publish-to-play-store:
|
||||
description: "Optional. Deploy bundle artifact to Google Play Store"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 17
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder Job
|
||||
build:
|
||||
name: Build Authenticator
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Placeholder Step
|
||||
run: echo "placeholder workflow"
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Check Authenticator
|
||||
run: bundle exec fastlane checkAuthenticator
|
||||
|
||||
- name: Build Authenticator
|
||||
run: bundle exec fastlane buildAuthenticatorDebug
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Authenticator Play Store artifacts
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: ["aab", "apk"]
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
mkdir -p ${{ github.workspace }}/keystores
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_apk-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_apk-keystore.jks --output none
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_aab-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_aab-keystore.jks --output none
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name com.bitwarden.authenticator-google-services.json --file ${{ github.workspace }}/authenticator/src/google-services.json --output none
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if : ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
|
||||
|
||||
- name: Download Play Store credentials
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }}
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Increment version
|
||||
run: |
|
||||
DEFAULT_VERSION_CODE=$GITHUB_RUN_NUMBER
|
||||
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
|
||||
bundle exec fastlane setAuthenticatorBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:${{ inputs.version-name || '' }}
|
||||
|
||||
regex='versionName = "([^"]+)"'
|
||||
if [[ "$(cat authenticator/build.gradle.kts)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version Number: $VERSION_CODE" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
run: |
|
||||
bundle exec fastlane bundleAuthenticatorRelease \
|
||||
storeFile:${{ github.workspace }}/keystores/authenticator_aab-keystore.jks \
|
||||
storePassword:'${{ secrets.AAB_KEYSTORE_STORE_PASSWORD }}' \
|
||||
keyAlias:authenticatorupload \
|
||||
keyPassword:'${{ secrets.AAB_KEYSTORE_KEY_PASSWORD }}'
|
||||
|
||||
- name: Generate release Play Store APK
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
run: |
|
||||
bundle exec fastlane buildAuthenticatorRelease \
|
||||
storeFile:${{ github.workspace }}/keystores/authenticator_apk-keystore.jks \
|
||||
storePassword:'${{ secrets.APK_KEYSTORE_STORE_PASSWORD }}' \
|
||||
keyAlias:bitwardenauthenticator \
|
||||
keyPassword:'${{ secrets.APK_KEYSTORE_KEY_PASSWORD }}'
|
||||
|
||||
- name: Upload release Play Store .aab artifact
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload release .apk artifact
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create checksum file for Release AAB
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
run: |
|
||||
sha256sum "authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab" \
|
||||
> ./authenticator-android-aab-sha256.txt
|
||||
|
||||
- name: Create checksum for release .apk artifact
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
run: |
|
||||
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
|
||||
> ./authenticator-android-apk-sha256.txt
|
||||
|
||||
- name: Upload .apk SHA file for release
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .aab SHA file for release
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Publish release bundle to Firebase
|
||||
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
|
||||
serviceCredentialsFile:${{ env.FIREBASE_CREDS_PATH }}
|
||||
|
||||
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
|
||||
# bundles
|
||||
- name: Publish release bundle to Google Play Store
|
||||
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
|
||||
env:
|
||||
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
|
||||
run: |
|
||||
bundle exec fastlane publishAuthenticatorReleaseToGooglePlayStore \
|
||||
serviceCredentialsFile:${{ env.PLAY_STORE_CREDS_FILE }} \
|
||||
|
||||
53
.github/workflows/crowdin-pull-authenticator.yml
vendored
53
.github/workflows/crowdin-pull-authenticator.yml
vendored
@ -2,12 +2,55 @@ name: Crowdin Sync - Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
schedule:
|
||||
- cron: '0 0 * * 5'
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder Job
|
||||
crowdin-sync:
|
||||
name: Autosync
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "673718"
|
||||
steps:
|
||||
- name: Placeholder Step
|
||||
run: echo "placeholder workflow"
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Log in to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-gpg-private-key, github-gpg-private-key-passphrase"
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Download translations
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
||||
with:
|
||||
config: crowdin-bwa.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
github_user_name: "bitwarden-devops-bot"
|
||||
github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
commit_message: "Autosync the updated translations"
|
||||
localization_branch_name: crowdin-auto-sync
|
||||
create_pull_request: true
|
||||
pull_request_title: "Autosync Crowdin Translations"
|
||||
pull_request_body: "Autosync the updated translations"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
||||
27
.github/workflows/crowdin-push-authenticator.yml
vendored
27
.github/workflows/crowdin-push-authenticator.yml
vendored
@ -2,12 +2,29 @@ name: Crowdin Push - Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 17
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder Job
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "673718"
|
||||
steps:
|
||||
- name: Placeholder Step
|
||||
run: echo "placeholder workflow"
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Upload sources
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
||||
with:
|
||||
config: crowdin-bwa.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
71
.github/workflows/scan-authenticator.yml
vendored
71
.github/workflows/scan-authenticator.yml
vendored
@ -2,12 +2,75 @@ name: Scan Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder Job
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
sast:
|
||||
name: SAST scan
|
||||
runs-on: ubuntu-24.04
|
||||
needs: check-run
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Placeholder Step
|
||||
run: echo "placeholder workflow"
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
project_name: ${{ github.repository }}
|
||||
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
quality:
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-24.04
|
||||
needs: check-run
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
|
||||
|
||||
79
.github/workflows/test-authenticator.yml
vendored
79
.github/workflows/test-authenticator.yml
vendored
@ -1,13 +1,82 @@
|
||||
name: Test Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 17
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder Job
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
needs: check-run
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Placeholder Step
|
||||
run: echo "placeholder workflow"
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Build and test Authenticator
|
||||
run: |
|
||||
bundle exec fastlane checkAuthenticator
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
|
||||
with:
|
||||
files: authenticator/build/reports/kover/reportDebug.xml
|
||||
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
61
README-bwa.md
Normal file
61
README-bwa.md
Normal file
@ -0,0 +1,61 @@
|
||||
[](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml?query=branch:main)
|
||||
[](https://gitter.im/bitwarden/Lobby)
|
||||
|
||||
# Bitwarden Authenticator Android App
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.bitwarden.authenticator" target="_blank"><img alt="Get it on Google Play" src="https://imgur.com/YQzmZi9.png" width="153" height="46"></a>
|
||||
|
||||
Bitwarden Authenticator allows you easily store and generate two-factor authentication codes on your device. The Bitwarden Authenticator Android application is written in Kotlin.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/authenticator-android-codes.png" alt="" width="325" height="650" />
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Minimum SDK**: 28
|
||||
- **Target SDK**: 34
|
||||
- **Device Types Supported**: Phone and Tablet
|
||||
- **Orientations Supported**: Portrait and Landscape
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```sh
|
||||
$ git clone https://github.com/bitwarden/authenticator-android
|
||||
```
|
||||
|
||||
2. Create a `user.properties` file in the root directory of the project and add the following properties:
|
||||
|
||||
- `gitHubToken`: A "classic" Github Personal Access Token (PAT) with the `read:packages` scope (ex: `gitHubToken=gph_xx...xx`). These can be generated by going to the [Github tokens page](https://github.com/settings/tokens). See [the Github Packages user documentation concerning authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for more details.
|
||||
|
||||
3. Setup the code style formatter:
|
||||
|
||||
All code must follow the guidelines described in the [Code Style Guidelines document](docs/STYLE_AND_BEST_PRACTICES.md). To aid in adhering to these rules, all contributors should apply `docs/bitwarden-style.xml` as their code style scheme. In IntelliJ / Android Studio:
|
||||
|
||||
- Navigate to `Preferences > Editor > Code Style`.
|
||||
- Hit the `Manage` button next to `Scheme`.
|
||||
- Select `Import`.
|
||||
- Find the `bitwarden-style.xml` file in the project's `docs/` directory.
|
||||
- Import "from" `BitwardenStyle` "to" `BitwardenStyle`.
|
||||
- Hit `Apply` and `OK` to save the changes and exit Preferences.
|
||||
|
||||
Note that in some cases you may need to restart Android Studio for the changes to take effect.
|
||||
|
||||
All code should be formatted before submitting a pull request. This can be done manually but it can also be helpful to create a macro with a custom keyboard binding to auto-format when saving. In Android Studio on OS X:
|
||||
|
||||
- Select `Edit > Macros > Start Macro Recording`
|
||||
- Select `Code > Optimize Imports`
|
||||
- Select `Code > Reformat Code`
|
||||
- Select `File > Save All`
|
||||
- Select `Edit > Macros > Stop Macro Recording`
|
||||
|
||||
This can then be mapped to a set of keys by navigating to `Android Studio > Preferences` and editing the macro under `Keymap` (ex : shift + command + s).
|
||||
|
||||
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
|
||||
|
||||
## Contribute
|
||||
|
||||
Code contributions are welcome! Please commit any pull requests against the `main` branch. Learn more about how to contribute by reading the [Contributing Guidelines](https://contributing.bitwarden.com/contributing/). Check out the [Contributing Documentation](https://contributing.bitwarden.com/) for how to get started with your first contribution.
|
||||
|
||||
Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file.
|
||||
1
authenticator/.gitignore
vendored
Normal file
1
authenticator/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
||||
329
authenticator/build.gradle.kts
Normal file
329
authenticator/build.gradle.kts
Normal file
@ -0,0 +1,329 @@
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import com.google.protobuf.gradle.proto
|
||||
import dagger.hilt.android.plugin.util.capitalize
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.crashlytics)
|
||||
alias(libs.plugins.detekt)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose.compiler)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlinx.kover)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.google.protobuf)
|
||||
alias(libs.plugins.google.services)
|
||||
alias(libs.plugins.sonarqube)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.bitwarden.authenticator"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.bitwarden.authenticator"
|
||||
minSdk = libs.versions.minSdkBwa.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
setProperty("archivesBaseName", "com.bitwarden.authenticator")
|
||||
|
||||
ksp {
|
||||
// The location in which the generated Room Database Schemas will be stored in the repo.
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
@Suppress("UnstableApiUsage")
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
getByName("debug") {
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
storeFile = file("../keystores/debug-bwa.keystore")
|
||||
storePassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".dev"
|
||||
manifestPlaceholders["targetBitwardenAppId"] = "com.x8bit.bitwarden.dev"
|
||||
buildConfigField(
|
||||
type = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType",
|
||||
name = "AUTHENTICATOR_BRIDGE_CONNECTION_TYPE",
|
||||
value = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType.DEV",
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
|
||||
}
|
||||
|
||||
release {
|
||||
manifestPlaceholders["targetBitwardenAppId"] = "com.x8bit.bitwarden"
|
||||
buildConfigField(
|
||||
type = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType",
|
||||
name = "AUTHENTICATOR_BRIDGE_CONNECTION_TYPE",
|
||||
value = "com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType.RELEASE",
|
||||
)
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
|
||||
}
|
||||
}
|
||||
applicationVariants.all {
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
outputs
|
||||
.mapNotNull { it as? BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
// Set the APK output filename.
|
||||
output.outputFileName = "$applicationId.apk"
|
||||
|
||||
val variantName = name
|
||||
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-${buildType.name}.aab",
|
||||
"$applicationId.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
tasks
|
||||
.getByName("bundle${variantName.capitalize()}")
|
||||
.finalizedBy(renameTaskName)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1,LICENSE*.md}"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
proto {
|
||||
srcDir("src/main/proto")
|
||||
}
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable += listOf(
|
||||
"MissingTranslation",
|
||||
"ExtraTranslation",
|
||||
)
|
||||
}
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
// Required for Robolectric
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(files("libs/authenticatorbridge-1.0.0-release.aar"))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.autofill)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.biometrics)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
testImplementation(libs.testng)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.bitwarden.sdk)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(platform(libs.google.firebase.bom))
|
||||
implementation(libs.google.firebase.cloud.messaging)
|
||||
implementation(libs.google.firebase.crashlytics)
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.google.guava)
|
||||
implementation(libs.google.protobuf.javalite)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.square.okhttp)
|
||||
implementation(libs.square.okhttp.logging)
|
||||
implementation(platform(libs.square.retrofit.bom))
|
||||
implementation(libs.square.retrofit)
|
||||
implementation(libs.square.retrofit.kotlinx.serialization)
|
||||
implementation(libs.zxing.zxing.core)
|
||||
|
||||
// For now we are restricted to running Compose tests for debug builds only
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.androidx.compose.ui.test)
|
||||
testImplementation(libs.google.hilt.android.testing)
|
||||
testImplementation(libs.junit.junit5)
|
||||
testImplementation(libs.junit.vintage)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk.mockk)
|
||||
testImplementation(libs.robolectric.robolectric)
|
||||
testImplementation(libs.square.okhttp.mockwebserver)
|
||||
testImplementation(libs.square.turbine)
|
||||
|
||||
detektPlugins(libs.detekt.detekt.formatting)
|
||||
detektPlugins(libs.detekt.detekt.rules)
|
||||
}
|
||||
|
||||
detekt {
|
||||
autoCorrect = true
|
||||
config.from(files("$rootDir/detekt-config.yml"))
|
||||
}
|
||||
|
||||
kover {
|
||||
currentProject {
|
||||
sources {
|
||||
excludeJava = true
|
||||
}
|
||||
}
|
||||
reports {
|
||||
filters {
|
||||
excludes {
|
||||
androidGeneratedClasses()
|
||||
annotatedBy(
|
||||
// Compose previews
|
||||
"androidx.compose.ui.tooling.preview.Preview",
|
||||
// Manually excluded classes/files/etc.
|
||||
"com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage",
|
||||
)
|
||||
classes(
|
||||
// Navigation helpers
|
||||
"*.*NavigationKt*",
|
||||
// Composable singletons
|
||||
"*.*ComposableSingletons*",
|
||||
// Generated classes related to interfaces with default values
|
||||
"*.*DefaultImpls*",
|
||||
// Databases
|
||||
"*.database.*Database*",
|
||||
"*.dao.*Dao*",
|
||||
// Dagger Hilt
|
||||
"dagger.hilt.*",
|
||||
"hilt_aggregated_deps.*",
|
||||
"*_Factory",
|
||||
"*_Factory\$*",
|
||||
"*_*Factory",
|
||||
"*_*Factory\$*",
|
||||
"*.Hilt_*",
|
||||
"*_HiltModules",
|
||||
"*_HiltModules\$*",
|
||||
"*_Impl",
|
||||
"*_Impl\$*",
|
||||
"*_MembersInjector",
|
||||
)
|
||||
packages(
|
||||
// Dependency injection
|
||||
"*.di",
|
||||
// Models
|
||||
"*.model",
|
||||
// Custom UI components
|
||||
"com.bitwarden.authenticator.ui.platform.components",
|
||||
// Theme-related code
|
||||
"com.bitwarden.authenticator.ui.platform.theme",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = libs.google.protobuf.protoc.get().toString()
|
||||
}
|
||||
generateProtoTasks {
|
||||
this.all().forEach { task ->
|
||||
task.builtins.create("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "bitwarden_authenticator-android")
|
||||
property("sonar.organization", "bitwarden")
|
||||
property("sonar.host.url", "https://sonarcloud.io")
|
||||
property("sonar.sources", "authenticator/src/")
|
||||
property("sonar.tests", "authenticator/src/")
|
||||
property("sonar.test.inclusions", "authenticator/src/test/")
|
||||
property("sonar.exclusions", "authenticator/src/test/")
|
||||
}
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
getByName("sonar") {
|
||||
dependsOn("check")
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameFile(path: String, newName: String) {
|
||||
val originalFile = File(path)
|
||||
if (!originalFile.exists()) {
|
||||
println("File $originalFile does not exist!")
|
||||
return
|
||||
}
|
||||
|
||||
val newFile = File(originalFile.parentFile, newName)
|
||||
if (originalFile.renameTo(newFile)) {
|
||||
println("Renamed $originalFile to $newFile")
|
||||
} else {
|
||||
throw RuntimeException("Failed to rename $originalFile to $newFile")
|
||||
}
|
||||
}
|
||||
BIN
authenticator/libs/authenticatorbridge-1.0.0-release.aar
Normal file
BIN
authenticator/libs/authenticatorbridge-1.0.0-release.aar
Normal file
Binary file not shown.
112
authenticator/proguard-rules.pro
vendored
Normal file
112
authenticator/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
################################################################################
|
||||
# Bitwarden SDK
|
||||
################################################################################
|
||||
|
||||
# We need to access the SDK using JNA and this makes it very easy to obfuscate away the SDK unless
|
||||
# we keep it here.
|
||||
-keep class com.bitwarden.** { *; }
|
||||
|
||||
################################################################################
|
||||
# Bitwarden Models
|
||||
################################################################################
|
||||
|
||||
# Keep all enums
|
||||
-keepclassmembers enum * { *; }
|
||||
|
||||
################################################################################
|
||||
# Credential Manager
|
||||
################################################################################
|
||||
|
||||
-if class androidx.credentials.CredentialManager
|
||||
-keep class androidx.credentials.playservices.** {
|
||||
*;
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Firebase Crashlytics
|
||||
################################################################################
|
||||
|
||||
# Keep file names and line numbers.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Keep custom exceptions.
|
||||
-keep public class * extends java.lang.Exception
|
||||
|
||||
################################################################################
|
||||
# kotlinx.serialization
|
||||
################################################################################
|
||||
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
|
||||
# kotlinx-serialization-json specific.
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Glide
|
||||
################################################################################
|
||||
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep public class * extends com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
################################################################################
|
||||
# Google Protobuf generated files
|
||||
################################################################################
|
||||
|
||||
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||
|
||||
################################################################################
|
||||
# JNA
|
||||
################################################################################
|
||||
|
||||
# See https://github.com/java-native-access/jna/blob/fdb8695fb9b05fba467dadfe5735282f8bcc053d/www/FrequentlyAskedQuestions.md#jna-on-android
|
||||
-dontwarn java.awt.*
|
||||
-keep class com.sun.jna.* { *; }
|
||||
-keepclassmembers class * extends com.sun.jna.* { public *; }
|
||||
|
||||
# Keep annotated classes
|
||||
-keep @com.sun.jna.* class *
|
||||
-keepclassmembers class * {
|
||||
@com.sun.jna.* *;
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Okhttp/Retrofit https://square.github.io/okhttp/ & https://square.github.io/retrofit/
|
||||
################################################################################
|
||||
|
||||
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
|
||||
# EnclosingMethod is required to use InnerClasses.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
|
||||
# Retrofit does reflection on method and parameter annotations.
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
|
||||
# https://github.com/square/okhttp/blob/339732e3a1b78be5d792860109047f68a011b5eb/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro#L11-L14
|
||||
-dontwarn okhttp3.internal.platform.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
# Related to this issue on https://github.com/square/retrofit/issues/3880
|
||||
# Check https://github.com/square/retrofit/tags for new versions
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.Result
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
# This solves this issue https://github.com/square/retrofit/issues/3880
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
|
||||
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
|
||||
################################################################################
|
||||
# ZXing
|
||||
################################################################################
|
||||
|
||||
# Suppress zxing missing class error due to circular references
|
||||
-dontwarn com.google.zxing.BarcodeFormat
|
||||
-dontwarn com.google.zxing.EncodeHintType
|
||||
-dontwarn com.google.zxing.MultiFormatWriter
|
||||
-dontwarn com.google.zxing.common.BitMatrix
|
||||
@ -0,0 +1,82 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "480a4540e7704429515a28eb9c38ab14",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "items",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT NOT NULL, `userId` TEXT, `accountName` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "algorithm",
|
||||
"columnName": "algorithm",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "period",
|
||||
"columnName": "period",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "digits",
|
||||
"columnName": "digits",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "issuer",
|
||||
"columnName": "issuer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '480a4540e7704429515a28eb9c38ab14')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "a4f979e34bbd67f2ddd263acdad12a38",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "items",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT NOT NULL, `userId` TEXT, `accountName` TEXT, `favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "algorithm",
|
||||
"columnName": "algorithm",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "period",
|
||||
"columnName": "period",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "digits",
|
||||
"columnName": "digits",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "issuer",
|
||||
"columnName": "issuer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "favorite",
|
||||
"columnName": "favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a4f979e34bbd67f2ddd263acdad12a38')"
|
||||
]
|
||||
}
|
||||
}
|
||||
20
authenticator/src/debug/AndroidManifest.xml
Normal file
20
authenticator/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
|
||||
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Allows changing locales -->
|
||||
<uses-permission
|
||||
android:name="android.permission.CHANGE_CONFIGURATION"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<application tools:ignore="MissingApplicationIcon">
|
||||
<!-- Disable Crashlytics for debug builds -->
|
||||
<meta-data
|
||||
android:name="firebase_crashlytics_collection_enabled"
|
||||
android:value="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
29
authenticator/src/debug/google-services.json
Normal file
29
authenticator/src/debug/google-services.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "867301491091",
|
||||
"project_id": "bitwarden-authenticator",
|
||||
"storage_bucket": "bitwarden-authenticator.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:867301491091:android:3ee369dedcd20f6551e866",
|
||||
"android_client_info": {
|
||||
"package_name": "com.bitwarden.authenticator.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDDXnnBuWzuh8rlihiMWRPif_sqkGk3fxw"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
60
authenticator/src/main/AndroidManifest.xml
Normal file
60
authenticator/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="${targetBitwardenAppId}.permission.AUTHENTICATOR_BRIDGE_SERVICE" />
|
||||
|
||||
<application
|
||||
android:name=".AuthenticatorApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name="com.bitwarden.authenticator.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="@integer/launchModeAPILevel"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- This is required to support in-app language picker in Android 12 (API 32) and below -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
</intent>
|
||||
|
||||
<package android:name="${targetBitwardenAppId}" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@ -0,0 +1,18 @@
|
||||
package com.bitwarden.authenticator
|
||||
|
||||
import android.app.Application
|
||||
import com.bitwarden.authenticator.data.platform.manager.CrashLogsManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Custom application class.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class AuthenticatorApplication : Application() {
|
||||
// Inject classes here that must be triggered on startup but are not otherwise consumed by
|
||||
// other callers.
|
||||
|
||||
@Inject
|
||||
lateinit var crashLogsManager: CrashLogsManager
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.bitwarden.authenticator
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.bitwarden.authenticator.data.platform.util.isSuspicious
|
||||
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
|
||||
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
|
||||
import com.bitwarden.authenticator.ui.platform.feature.rootnav.RootNavScreen
|
||||
import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Primary entry point for the application.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
|
||||
@Inject
|
||||
lateinit var debugLaunchManager: DebugMenuLaunchManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
sanitizeIntent()
|
||||
var shouldShowSplashScreen = true
|
||||
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
mainViewModel.trySendAction(
|
||||
MainAction.ReceiveFirstIntent(
|
||||
intent = intent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setContent {
|
||||
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
observeViewModelEvents(navController)
|
||||
AuthenticatorTheme(
|
||||
theme = state.theme,
|
||||
) {
|
||||
RootNavScreen(
|
||||
navController = navController,
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
onExitApplication = { finishAffinity() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
sanitizeIntent()
|
||||
mainViewModel.trySendAction(
|
||||
MainAction.ReceiveNewIntent(intent = intent),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeIntent() {
|
||||
if (intent.isSuspicious) {
|
||||
intent = Intent(
|
||||
/* packageContext = */ this,
|
||||
/* cls = */ MainActivity::class.java,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModelEvents(navController: NavHostController) {
|
||||
mainViewModel
|
||||
.eventFlow
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is MainEvent.ScreenCaptureSettingChange -> {
|
||||
handleScreenCaptureSettingChange(event)
|
||||
}
|
||||
|
||||
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager
|
||||
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
|
||||
.takeIf { it }
|
||||
?: super.dispatchTouchEvent(event)
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager
|
||||
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
|
||||
.takeIf { it }
|
||||
?: super.dispatchKeyEvent(event)
|
||||
|
||||
private fun sendOpenDebugMenuEvent() {
|
||||
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
|
||||
}
|
||||
|
||||
private fun handleScreenCaptureSettingChange(event: MainEvent.ScreenCaptureSettingChange) {
|
||||
if (event.isAllowed) {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
package com.bitwarden.authenticator
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository
|
||||
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
||||
import com.bitwarden.authenticator.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A view model that helps launch actions for the [MainActivity].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
settingsRepository: SettingsRepository,
|
||||
configRepository: ServerConfigRepository,
|
||||
) : BaseViewModel<MainState, MainEvent, MainAction>(
|
||||
MainState(
|
||||
theme = settingsRepository.appTheme,
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
settingsRepository
|
||||
.appThemeStateFlow
|
||||
.onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.isScreenCaptureAllowedStateFlow
|
||||
.onEach { isAllowed ->
|
||||
sendEvent(MainEvent.ScreenCaptureSettingChange(isAllowed))
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
viewModelScope.launch {
|
||||
configRepository.getServerConfig(forceRefresh = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(action: MainAction) {
|
||||
when (action) {
|
||||
is MainAction.Internal.ThemeUpdate -> handleThemeUpdated(action)
|
||||
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
|
||||
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
|
||||
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOpenDebugMenu() {
|
||||
sendEvent(MainEvent.NavigateToDebugMenu)
|
||||
}
|
||||
|
||||
private fun handleThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
|
||||
mutableStateFlow.update { it.copy(theme = action.theme) }
|
||||
}
|
||||
|
||||
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
|
||||
handleIntent(
|
||||
intent = action.intent,
|
||||
isFirstIntent = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) {
|
||||
handleIntent(
|
||||
intent = action.intent,
|
||||
isFirstIntent = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleIntent(
|
||||
intent: Intent,
|
||||
isFirstIntent: Boolean,
|
||||
) {
|
||||
// RFU
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state for the [MainActivity].
|
||||
*/
|
||||
@Parcelize
|
||||
data class MainState(
|
||||
val theme: AppTheme,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Models actions for the [MainActivity].
|
||||
*/
|
||||
sealed class MainAction {
|
||||
/**
|
||||
* Receive first Intent by the application.
|
||||
*/
|
||||
data class ReceiveFirstIntent(val intent: Intent) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive Intent by the application.
|
||||
*/
|
||||
data class ReceiveNewIntent(val intent: Intent) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive event to open the debug menu.
|
||||
*/
|
||||
data object OpenDebugMenu : MainAction()
|
||||
|
||||
/**
|
||||
* Actions for internal use by the ViewModel.
|
||||
*/
|
||||
sealed class Internal : MainAction() {
|
||||
|
||||
/**
|
||||
* Indicates that the app theme has changed.
|
||||
*/
|
||||
data class ThemeUpdate(
|
||||
val theme: AppTheme,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents events that are emitted by the [MainViewModel].
|
||||
*/
|
||||
sealed class MainEvent {
|
||||
|
||||
/**
|
||||
* Navigate to the debug menu.
|
||||
*/
|
||||
data object NavigateToDebugMenu : MainEvent()
|
||||
|
||||
/**
|
||||
* Event indicating a change in the screen capture setting.
|
||||
*/
|
||||
data class ScreenCaptureSettingChange(val isAllowed: Boolean) : MainEvent()
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitwarden.authenticator.data.auth.datasource.disk
|
||||
|
||||
/**
|
||||
* Primary access point for disk information.
|
||||
*/
|
||||
interface AuthDiskSource {
|
||||
|
||||
/**
|
||||
* Retrieves the "last active time".
|
||||
*
|
||||
* This time is intended to be derived from a call to
|
||||
* [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime())
|
||||
*/
|
||||
fun getLastActiveTimeMillis(): Long?
|
||||
|
||||
/**
|
||||
* Stores the [lastActiveTimeMillis] .
|
||||
*
|
||||
* This time is intended to be derived from a call to
|
||||
* [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime())
|
||||
*/
|
||||
fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?)
|
||||
|
||||
/**
|
||||
* Gets the biometrics key.
|
||||
*/
|
||||
fun getUserBiometricUnlockKey(): String?
|
||||
|
||||
/**
|
||||
* Stores the biometrics key.
|
||||
*/
|
||||
fun storeUserBiometricUnlockKey(biometricsKey: String?)
|
||||
|
||||
/**
|
||||
* Stores the symmetric key data used for encrypting TOTP data.
|
||||
*/
|
||||
var authenticatorBridgeSymmetricSyncKey: ByteArray?
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.bitwarden.authenticator.data.auth.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseEncryptedDiskSource
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseEncryptedDiskSource.Companion.ENCRYPTED_BASE_KEY
|
||||
|
||||
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY =
|
||||
"$ENCRYPTED_BASE_KEY:authenticatorSyncSymmetricKey"
|
||||
private const val LAST_ACTIVE_TIME_KEY = "$BASE_KEY:lastActiveTime"
|
||||
private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricUnlock"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
*/
|
||||
class AuthDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
AuthDiskSource {
|
||||
|
||||
override fun getLastActiveTimeMillis(): Long? =
|
||||
getLong(key = LAST_ACTIVE_TIME_KEY)
|
||||
|
||||
override fun storeLastActiveTimeMillis(
|
||||
lastActiveTimeMillis: Long?,
|
||||
) {
|
||||
putLong(
|
||||
key = LAST_ACTIVE_TIME_KEY,
|
||||
value = lastActiveTimeMillis,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getUserBiometricUnlockKey(): String? =
|
||||
getEncryptedString(key = BIOMETRICS_UNLOCK_KEY)
|
||||
|
||||
override fun storeUserBiometricUnlockKey(
|
||||
biometricsKey: String?,
|
||||
) {
|
||||
putEncryptedString(
|
||||
key = BIOMETRICS_UNLOCK_KEY,
|
||||
value = biometricsKey,
|
||||
)
|
||||
}
|
||||
|
||||
override var authenticatorBridgeSymmetricSyncKey: ByteArray?
|
||||
set(value) {
|
||||
val asString = value?.let { value.toString(Charsets.ISO_8859_1) }
|
||||
putEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY, asString)
|
||||
}
|
||||
get() = getEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY)
|
||||
?.toByteArray(Charsets.ISO_8859_1)
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.bitwarden.authenticator.data.auth.datasource.disk.di
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSourceImpl
|
||||
import com.bitwarden.authenticator.data.platform.datasource.di.EncryptedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.di.UnencryptedPreferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides persistence-related dependencies in the auth package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
): AuthDiskSource =
|
||||
AuthDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.bitwarden.authenticator.data.auth.datasource.disk.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents URLs for various Bitwarden domains.
|
||||
*
|
||||
* @property base The overall base URL.
|
||||
* @property api Separate base URL for the "/api" domain (if applicable).
|
||||
* @property identity Separate base URL for the "/identity" domain (if applicable).
|
||||
* @property icon Separate base URL for the icon domain (if applicable).
|
||||
* @property notifications Separate base URL for the notifications domain (if applicable).
|
||||
* @property webVault Separate base URL for the web vault domain (if applicable).
|
||||
* @property events Separate base URL for the events domain (if applicable).
|
||||
*/
|
||||
@Serializable
|
||||
data class EnvironmentUrlDataJson(
|
||||
@SerialName("base")
|
||||
val base: String,
|
||||
|
||||
@SerialName("api")
|
||||
val api: String? = null,
|
||||
|
||||
@SerialName("identity")
|
||||
val identity: String? = null,
|
||||
|
||||
@SerialName("icons")
|
||||
val icon: String? = null,
|
||||
|
||||
@SerialName("notifications")
|
||||
val notifications: String? = null,
|
||||
|
||||
@SerialName("webVault")
|
||||
val webVault: String? = null,
|
||||
|
||||
@SerialName("events")
|
||||
val events: String? = null,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Default [EnvironmentUrlDataJson] for the US region.
|
||||
*/
|
||||
val DEFAULT_US: EnvironmentUrlDataJson =
|
||||
EnvironmentUrlDataJson(base = "https://vault.bitwarden.com")
|
||||
|
||||
/**
|
||||
* Default [EnvironmentUrlDataJson] for the EU region.
|
||||
*/
|
||||
val DEFAULT_EU: EnvironmentUrlDataJson =
|
||||
EnvironmentUrlDataJson(base = "https://vault.bitwarden.eu")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.bitwarden.authenticator.data.auth.repository
|
||||
|
||||
/**
|
||||
* Provides and API for modifying authentication state.
|
||||
*/
|
||||
interface AuthRepository {
|
||||
|
||||
/**
|
||||
* Updates the "last active time" for the current user.
|
||||
*/
|
||||
fun updateLastActiveTime()
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.bitwarden.authenticator.data.auth.repository
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthRepository].
|
||||
*/
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : AuthRepository {
|
||||
|
||||
/**
|
||||
* Updates the "last active time" for the current user.
|
||||
*/
|
||||
override fun updateLastActiveTime() {
|
||||
authDiskSource.storeLastActiveTimeMillis(
|
||||
lastActiveTimeMillis = elapsedRealtimeMillisProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.bitwarden.authenticator.data.auth.repository.di
|
||||
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Provides repositories in the auth package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthRepositoryModule {
|
||||
|
||||
@Provides
|
||||
fun provideAuthRepository(
|
||||
authDiskSource: AuthDiskSource,
|
||||
): AuthRepository = AuthRepositoryImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Primary access point for disk information related to authenticator data.
|
||||
*/
|
||||
interface AuthenticatorDiskSource {
|
||||
|
||||
/**
|
||||
* Saves an authenticator item to the data source.
|
||||
*/
|
||||
suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity)
|
||||
|
||||
/**
|
||||
* Retrieves all authenticator items from the data source.
|
||||
*/
|
||||
fun getItems(): Flow<List<AuthenticatorItemEntity>>
|
||||
|
||||
/**
|
||||
* Deletes an authenticator item from the data source with the given [itemId].
|
||||
*/
|
||||
suspend fun deleteItem(itemId: String)
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthenticatorDiskSource].
|
||||
*/
|
||||
class AuthenticatorDiskSourceImpl @Inject constructor(
|
||||
private val itemDao: ItemDao,
|
||||
) : AuthenticatorDiskSource {
|
||||
|
||||
private val forceItemsFlow = bufferedMutableSharedFlow<List<AuthenticatorItemEntity>>()
|
||||
|
||||
override suspend fun saveItem(vararg authenticatorItem: AuthenticatorItemEntity) {
|
||||
itemDao.insert(*authenticatorItem)
|
||||
}
|
||||
|
||||
override fun getItems(): Flow<List<AuthenticatorItemEntity>> = merge(
|
||||
forceItemsFlow,
|
||||
itemDao.getAllItems(),
|
||||
)
|
||||
|
||||
override suspend fun deleteItem(itemId: String) {
|
||||
itemDao.deleteItem(itemId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor
|
||||
|
||||
import androidx.room.ProvidedTypeConverter
|
||||
import androidx.room.TypeConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert [AuthenticatorItemAlgorithm] to and from a [String].
|
||||
*/
|
||||
@ProvidedTypeConverter
|
||||
class AuthenticatorItemAlgorithmConverter {
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert an [AuthenticatorItemAlgorithm] to a [String].
|
||||
*/
|
||||
@TypeConverter
|
||||
fun toString(item: AuthenticatorItemAlgorithm): String = item.name
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert a [String] to an [AuthenticatorItemAlgorithm].
|
||||
*/
|
||||
@TypeConverter
|
||||
fun fromString(itemName: String) = AuthenticatorItemAlgorithm
|
||||
.entries
|
||||
.find { it.name == itemName }
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor
|
||||
|
||||
import androidx.room.ProvidedTypeConverter
|
||||
import androidx.room.TypeConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert [AuthenticatorItemType] to and from a [String].
|
||||
*/
|
||||
@ProvidedTypeConverter
|
||||
class AuthenticatorItemTypeConverter {
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert an [AuthenticatorItemType] to a [String].
|
||||
*/
|
||||
@TypeConverter
|
||||
fun toString(item: AuthenticatorItemType): String = item.name
|
||||
|
||||
/**
|
||||
* A [TypeConverter] to convert a [String] to an [AuthenticatorItemType].
|
||||
*/
|
||||
@TypeConverter
|
||||
fun fromString(itemName: String) = AuthenticatorItemType
|
||||
.entries
|
||||
.find { it.name == itemName }
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Provides methods for inserting, reading, and deleting authentication items from the database
|
||||
* using [AuthenticatorItemEntity].
|
||||
*/
|
||||
@Dao
|
||||
interface ItemDao {
|
||||
|
||||
/**
|
||||
* Inserts a single authenticator item into the database.
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(vararg item: AuthenticatorItemEntity)
|
||||
|
||||
/**
|
||||
* Read all authenticator items from the database.
|
||||
*/
|
||||
@Query("SELECT * FROM items")
|
||||
fun getAllItems(): Flow<List<AuthenticatorItemEntity>>
|
||||
|
||||
/**
|
||||
* Deletes the specified authenticator item with the given [itemId]. This will return the number
|
||||
* of rows deleted by this query.
|
||||
*/
|
||||
@Query("DELETE FROM items WHERE id = :itemId")
|
||||
suspend fun deleteItem(itemId: String): Int
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.database
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemAlgorithmConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemTypeConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
|
||||
/**
|
||||
* Room database for storing any persisted data.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
AuthenticatorItemEntity::class,
|
||||
],
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(
|
||||
AuthenticatorItemTypeConverter::class,
|
||||
AuthenticatorItemAlgorithmConverter::class,
|
||||
)
|
||||
abstract class AuthenticatorDatabase : RoomDatabase() {
|
||||
|
||||
/**
|
||||
* Provide the DAO for accessing authenticator item data.
|
||||
*/
|
||||
abstract fun itemDao(): ItemDao
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.di
|
||||
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSourceImpl
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemAlgorithmConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.convertor.AuthenticatorItemTypeConverter
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.dao.ItemDao
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides persistence related dependencies in the authenticator package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthenticatorDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorDatabase(app: Application): AuthenticatorDatabase =
|
||||
Room
|
||||
.databaseBuilder(
|
||||
context = app,
|
||||
klass = AuthenticatorDatabase::class.java,
|
||||
name = "authenticator_database",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addTypeConverter(AuthenticatorItemTypeConverter())
|
||||
.addTypeConverter(AuthenticatorItemAlgorithmConverter())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideItemDao(database: AuthenticatorDatabase) = database.itemDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorDiskSource(itemDao: ItemDao): AuthenticatorDiskSource =
|
||||
AuthenticatorDiskSourceImpl(itemDao = itemDao)
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm.entries
|
||||
|
||||
/**
|
||||
* Enum class representing SHA algorithms an authenticator item may be hashed with.
|
||||
*/
|
||||
enum class AuthenticatorItemAlgorithm {
|
||||
|
||||
/**
|
||||
* Authenticator item verification code uses SHA1 hash.
|
||||
*/
|
||||
SHA1,
|
||||
|
||||
/**
|
||||
* Authenticator item verification code uses SHA256 hash.
|
||||
*/
|
||||
SHA256,
|
||||
|
||||
/**
|
||||
* Authenticator item verification code uses SHA512 hash.
|
||||
*/
|
||||
SHA512,
|
||||
;
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Returns a [AuthenticatorItemAlgorithm] with a name matching [value], or null.
|
||||
*/
|
||||
fun fromStringOrNull(value: String): AuthenticatorItemAlgorithm? =
|
||||
entries.find { it.name.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.text.htmlEncode
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Entity representing an authenticator item in the database.
|
||||
*/
|
||||
@Entity(tableName = "items")
|
||||
data class AuthenticatorItemEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "id")
|
||||
val id: String,
|
||||
|
||||
@ColumnInfo(name = "key")
|
||||
val key: String,
|
||||
|
||||
@ColumnInfo(name = "type")
|
||||
val type: AuthenticatorItemType = AuthenticatorItemType.TOTP,
|
||||
|
||||
@ColumnInfo(name = "algorithm")
|
||||
val algorithm: AuthenticatorItemAlgorithm = AuthenticatorItemAlgorithm.SHA1,
|
||||
|
||||
@ColumnInfo(name = "period")
|
||||
val period: Int = 30,
|
||||
|
||||
@ColumnInfo(name = "digits")
|
||||
val digits: Int = 6,
|
||||
|
||||
@ColumnInfo(name = "issuer")
|
||||
val issuer: String,
|
||||
|
||||
@ColumnInfo(name = "userId")
|
||||
val userId: String? = null,
|
||||
|
||||
@ColumnInfo(name = "accountName")
|
||||
val accountName: String? = null,
|
||||
|
||||
@ColumnInfo(name = "favorite", defaultValue = "0")
|
||||
val favorite: Boolean,
|
||||
) {
|
||||
/**
|
||||
* Returns the OTP data in a string formatted to match the Google Authenticator specification,
|
||||
* https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
*/
|
||||
fun toOtpAuthUriString(): String {
|
||||
return when (type) {
|
||||
AuthenticatorItemType.TOTP -> {
|
||||
val label = if (accountName.isNullOrBlank()) {
|
||||
issuer
|
||||
} else {
|
||||
"$issuer:$accountName"
|
||||
}
|
||||
Uri.Builder()
|
||||
.scheme("otpauth")
|
||||
.authority("totp")
|
||||
.appendPath(label.htmlEncode())
|
||||
.appendQueryParameter("secret", key)
|
||||
.appendQueryParameter("algorithm", algorithm.name)
|
||||
.appendQueryParameter("digits", digits.toString())
|
||||
.appendQueryParameter("period", period.toString())
|
||||
.appendQueryParameter("issuer", issuer)
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
|
||||
AuthenticatorItemType.STEAM -> {
|
||||
if (key.startsWith("steam://")) {
|
||||
key
|
||||
} else {
|
||||
"steam://$key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType.entries
|
||||
|
||||
/**
|
||||
* Enum representing the supported "type" options for authenticator items.
|
||||
*/
|
||||
enum class AuthenticatorItemType {
|
||||
|
||||
/**
|
||||
* A time-based one time password.
|
||||
*/
|
||||
TOTP,
|
||||
|
||||
/**
|
||||
* Steam's implementation of a one time password.
|
||||
*/
|
||||
STEAM,
|
||||
;
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Returns the [AuthenticatorItemType] matching [value], or null.
|
||||
*/
|
||||
fun fromStringOrNull(value: String): AuthenticatorItemType? =
|
||||
entries.find { it.name.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.sdk
|
||||
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.vault.TotpResponse
|
||||
|
||||
/**
|
||||
* Source of authenticator information from the Bitwarden SDK.
|
||||
*/
|
||||
interface AuthenticatorSdkSource {
|
||||
|
||||
/**
|
||||
* Generate a verification code and the period using the totp code.
|
||||
*/
|
||||
suspend fun generateTotp(
|
||||
totp: String,
|
||||
time: DateTime,
|
||||
): Result<TotpResponse>
|
||||
|
||||
/**
|
||||
* Generate a random key for seeding biometrics.
|
||||
*/
|
||||
suspend fun generateBiometricsKey(): Result<String>
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.sdk
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.manager.SdkClientManager
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.generators.PasswordGeneratorRequest
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.bitwarden.vault.TotpResponse
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthenticatorSdkSource].
|
||||
*/
|
||||
class AuthenticatorSdkSourceImpl @Inject constructor(
|
||||
private val sdkClientManager: SdkClientManager,
|
||||
) : AuthenticatorSdkSource {
|
||||
|
||||
override suspend fun generateTotp(
|
||||
totp: String,
|
||||
time: DateTime,
|
||||
): Result<TotpResponse> = runCatching {
|
||||
getClient()
|
||||
.vault()
|
||||
.generateTotp(
|
||||
key = totp,
|
||||
time = time,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun generateBiometricsKey(): Result<String> =
|
||||
runCatching {
|
||||
getClient()
|
||||
.generators()
|
||||
.password(
|
||||
PasswordGeneratorRequest(
|
||||
lowercase = true,
|
||||
uppercase = true,
|
||||
numbers = true,
|
||||
special = true,
|
||||
length = 7.toUByte(),
|
||||
avoidAmbiguous = true,
|
||||
minLowercase = null,
|
||||
minUppercase = null,
|
||||
minNumber = null,
|
||||
minSpecial = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getClient(): Client = sdkClientManager.getOrCreateClient()
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.datasource.sdk.di
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSourceImpl
|
||||
import com.bitwarden.authenticator.data.platform.manager.SdkClientManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides SDK-related dependencies for the authenticator package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthenticatorSdkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorSdkSource(
|
||||
sdkClientManager: SdkClientManager,
|
||||
): AuthenticatorSdkSource = AuthenticatorSdkSourceImpl(sdkClientManager)
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Manages reading and writing files.
|
||||
*/
|
||||
interface FileManager {
|
||||
|
||||
/**
|
||||
* Writes the given [dataString] to disk at the provided [fileUri]
|
||||
*/
|
||||
suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean
|
||||
|
||||
/**
|
||||
* Reads the [fileUri] into memory. A successful result will contain the raw [ByteArray].
|
||||
*/
|
||||
suspend fun uriToByteArray(fileUri: Uri): Result<ByteArray>
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* The buffer size to be used when reading from an input stream.
|
||||
*/
|
||||
private const val BUFFER_SIZE: Int = 1024
|
||||
|
||||
/**
|
||||
* Manages reading and writing files.
|
||||
*/
|
||||
class FileManagerImpl(
|
||||
private val context: Context,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : FileManager {
|
||||
|
||||
override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
return try {
|
||||
withContext(dispatcherManager.io) {
|
||||
context
|
||||
.contentResolver
|
||||
.openOutputStream(fileUri)
|
||||
?.use { outputStream ->
|
||||
outputStream.write(dataString.toByteArray())
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (exception: RuntimeException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uriToByteArray(fileUri: Uri): Result<ByteArray> =
|
||||
runCatching {
|
||||
withContext(dispatcherManager.io) {
|
||||
context
|
||||
.contentResolver
|
||||
.openInputStream(fileUri)
|
||||
?.use { inputStream ->
|
||||
ByteArrayOutputStream().use { outputStream ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } != -1) {
|
||||
outputStream.write(buffer, 0, length)
|
||||
}
|
||||
outputStream.toByteArray()
|
||||
}
|
||||
}
|
||||
?: throw IllegalStateException("Stream has crashed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Manages the flows for getting verification codes.
|
||||
*/
|
||||
interface TotpCodeManager {
|
||||
|
||||
/**
|
||||
* Flow for getting a DataState with multiple verification code items.
|
||||
*/
|
||||
fun getTotpCodesFlow(
|
||||
itemList: List<AuthenticatorItem>,
|
||||
): Flow<List<VerificationCodeItem>>
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
const val ALGORITHM_PARAM = "algorithm"
|
||||
const val DIGITS_PARAM = "digits"
|
||||
const val PERIOD_PARAM = "period"
|
||||
const val SECRET_PARAM = "secret"
|
||||
const val ISSUER_PARAM = "issuer"
|
||||
|
||||
/**
|
||||
* URI query parameter containing export data from Google Authenticator.
|
||||
*/
|
||||
const val DATA_PARAM = "data"
|
||||
const val TOTP_CODE_PREFIX = "otpauth://totp"
|
||||
const val STEAM_CODE_PREFIX = "steam://"
|
||||
const val GOOGLE_EXPORT_PREFIX = "otpauth-migration://"
|
||||
const val TOTP_DIGITS_DEFAULT = 6
|
||||
const val TOTP_DIGITS_MIN = 5
|
||||
const val TOTP_DIGITS_MAX = 10
|
||||
const val STEAM_DIGITS_DEFAULT = 5
|
||||
const val PERIOD_SECONDS_DEFAULT = 30
|
||||
val TOTP_DIGITS_RANGE = TOTP_DIGITS_MIN..TOTP_DIGITS_MAX
|
||||
val ALGORITHM_DEFAULT = AuthenticatorItemAlgorithm.SHA1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem
|
||||
import com.bitwarden.core.DateTime
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val ONE_SECOND_MILLISECOND = 1000L
|
||||
|
||||
/**
|
||||
* Primary implementation of [TotpCodeManager].
|
||||
*/
|
||||
class TotpCodeManagerImpl @Inject constructor(
|
||||
private val authenticatorSdkSource: AuthenticatorSdkSource,
|
||||
private val clock: Clock,
|
||||
) : TotpCodeManager {
|
||||
|
||||
override fun getTotpCodesFlow(
|
||||
itemList: List<AuthenticatorItem>,
|
||||
): Flow<List<VerificationCodeItem>> {
|
||||
if (itemList.isEmpty()) {
|
||||
return flowOf(emptyList())
|
||||
}
|
||||
val flows = itemList.map { it.toFlowOfVerificationCodes() }
|
||||
return combine(flows) { it.toList() }
|
||||
}
|
||||
|
||||
private fun AuthenticatorItem.toFlowOfVerificationCodes(): Flow<VerificationCodeItem> {
|
||||
val otpUri = this.otpUri
|
||||
return flow {
|
||||
var item: VerificationCodeItem? = null
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val time = (clock.millis() / ONE_SECOND_MILLISECOND).toInt()
|
||||
if (item == null || item.isExpired(clock)) {
|
||||
// If the item is expired or we haven't generated our first item,
|
||||
// generate a new code using the SDK:
|
||||
item = authenticatorSdkSource
|
||||
.generateTotp(otpUri, DateTime.now())
|
||||
.getOrNull()
|
||||
?.let { response ->
|
||||
VerificationCodeItem(
|
||||
code = response.code,
|
||||
periodSeconds = response.period.toInt(),
|
||||
timeLeftSeconds = response.period.toInt() -
|
||||
time % response.period.toInt(),
|
||||
issueTime = clock.millis(),
|
||||
id = when (source) {
|
||||
is AuthenticatorItem.Source.Local -> source.cipherId
|
||||
is AuthenticatorItem.Source.Shared -> UUID.randomUUID()
|
||||
.toString()
|
||||
},
|
||||
issuer = issuer,
|
||||
label = label,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
// We are assuming that our otp URIs can generate a valid code.
|
||||
// If they can't, we'll just silently omit that code from the list.
|
||||
currentCoroutineContext().cancel()
|
||||
return@flow
|
||||
}
|
||||
} else {
|
||||
// Item is not expired, just update time left:
|
||||
item = item.copy(
|
||||
timeLeftSeconds = item.periodSeconds - (time % item.periodSeconds),
|
||||
)
|
||||
}
|
||||
// Emit item
|
||||
emit(item)
|
||||
// Wait one second before heading to the top of the loop:
|
||||
delay(ONE_SECOND_MILLISECOND)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun VerificationCodeItem.isExpired(clock: Clock): Boolean {
|
||||
val timeExpired = issueTime + (timeLeftSeconds * ONE_SECOND_MILLISECOND)
|
||||
return timeExpired < clock.millis()
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager.di
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.FileManagerImpl
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManagerImpl
|
||||
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides managers in the authenticator package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthenticatorManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providerFileManager(
|
||||
@ApplicationContext context: Context,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): FileManager = FileManagerImpl(
|
||||
context = context,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTotpCodeManager(
|
||||
authenticatorSdkSource: AuthenticatorSdkSource,
|
||||
dispatcherManager: DispatcherManager,
|
||||
clock: Clock,
|
||||
): TotpCodeManager = TotpCodeManagerImpl(
|
||||
authenticatorSdkSource = authenticatorSdkSource,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models exported authenticator data in JSON format.
|
||||
*
|
||||
* This model is loosely based off of Bitwarden's exported unencrypted vault data.
|
||||
*/
|
||||
@Serializable
|
||||
data class ExportJsonData(
|
||||
val encrypted: Boolean,
|
||||
val items: List<ExportItem>,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Represents a single exported authenticator item.
|
||||
*
|
||||
* This model is loosely based off of Bitwarden's exported Cipher JSON.
|
||||
*/
|
||||
@Serializable
|
||||
data class ExportItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val folderId: String?,
|
||||
val organizationId: String?,
|
||||
val collectionIds: List<String>?,
|
||||
val notes: String?,
|
||||
val type: Int,
|
||||
val login: ItemLoginData?,
|
||||
val favorite: Boolean,
|
||||
) {
|
||||
/**
|
||||
* Represents the login specific data of an exported item.
|
||||
*
|
||||
* This model is loosely based off of Bitwarden's Cipher.Login JSON.
|
||||
*
|
||||
* @property totp OTP secret used to generate a verification code.
|
||||
*/
|
||||
@Serializable
|
||||
data class ItemLoginData(
|
||||
val totp: String?,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.manager.model
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem
|
||||
|
||||
/**
|
||||
* Models the items returned by the TotpCodeManager which are then used to display rows
|
||||
* of verification items.
|
||||
*
|
||||
* @property code The verification code for the item.
|
||||
* @property periodSeconds The time span where the code is valid in seconds.
|
||||
* @property timeLeftSeconds The seconds remaining until a new code is required.
|
||||
* @property issueTime The time the verification code was issued.
|
||||
* @property id The cipher id of the item.
|
||||
* @property username The username associated with the item.
|
||||
*/
|
||||
data class VerificationCodeItem(
|
||||
val code: String,
|
||||
val periodSeconds: Int,
|
||||
val timeLeftSeconds: Int,
|
||||
val issueTime: Long,
|
||||
val id: String,
|
||||
val issuer: String?,
|
||||
val label: String?,
|
||||
val source: AuthenticatorItem.Source,
|
||||
) {
|
||||
/**
|
||||
* The composite label of the authenticator item. Used for constructing an OTPAuth URI.
|
||||
* ```
|
||||
* label = issuer (“:” / “%3A”) *”%20” username
|
||||
* ```
|
||||
*/
|
||||
val otpAuthUriLabel = if (issuer != null) {
|
||||
issuer + label?.let { ":$it" }.orEmpty()
|
||||
} else {
|
||||
label.orEmpty()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat
|
||||
import com.bitwarden.authenticator.data.platform.repository.model.DataState
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat
|
||||
import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Provides and API for managing authenticator data.
|
||||
*/
|
||||
interface AuthenticatorRepository {
|
||||
|
||||
/**
|
||||
* Flow that represents the TOTP code result.
|
||||
*/
|
||||
val totpCodeFlow: Flow<TotpCodeResult>
|
||||
|
||||
/**
|
||||
* Flow that represents all ciphers for the active user.
|
||||
*
|
||||
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
|
||||
* must be collected in order to trigger state changes.
|
||||
*/
|
||||
val ciphersStateFlow: StateFlow<DataState<List<AuthenticatorItemEntity>>>
|
||||
|
||||
/**
|
||||
* Flow that represents the data for a specific vault item as found by ID. This may emit `null`
|
||||
* if the item cannot be found.
|
||||
*/
|
||||
fun getItemStateFlow(itemId: String): StateFlow<DataState<AuthenticatorItemEntity?>>
|
||||
|
||||
/**
|
||||
* State flow that represents the state of verification codes and accounts shared from the
|
||||
* main Bitwarden app.
|
||||
*/
|
||||
val sharedCodesStateFlow: StateFlow<SharedVerificationCodesState>
|
||||
|
||||
/**
|
||||
* Flow that represents the data for the TOTP verification codes for ciphers items.
|
||||
* This may emit an empty list if any issues arise during code generation.
|
||||
*/
|
||||
fun getLocalVerificationCodesFlow(): StateFlow<DataState<List<VerificationCodeItem>>>
|
||||
|
||||
/**
|
||||
* Emits the totp code result flow to listeners.
|
||||
*/
|
||||
fun emitTotpCodeResult(totpCodeResult: TotpCodeResult)
|
||||
|
||||
/**
|
||||
* Attempt to create a cipher.
|
||||
*/
|
||||
suspend fun createItem(item: AuthenticatorItemEntity): CreateItemResult
|
||||
|
||||
/**
|
||||
* Attempt to add provided [items].
|
||||
*/
|
||||
suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult
|
||||
|
||||
/**
|
||||
* Attempt to delete a cipher.
|
||||
*/
|
||||
suspend fun hardDeleteItem(itemId: String): DeleteItemResult
|
||||
|
||||
/**
|
||||
* Attempt to get the user's data for export.
|
||||
*/
|
||||
suspend fun exportVaultData(format: ExportVaultFormat, fileUri: Uri): ExportDataResult
|
||||
|
||||
/**
|
||||
* Attempt to read the user's data from a file
|
||||
*/
|
||||
suspend fun importVaultData(
|
||||
format: ImportFileFormat,
|
||||
fileData: IntentManager.FileData,
|
||||
): ImportDataResult
|
||||
|
||||
/**
|
||||
* Flow that emits `Unit` each time an account is synced from the main Bitwarden app for
|
||||
* the first time.
|
||||
*/
|
||||
val firstTimeAccountSyncFlow: Flow<Unit>
|
||||
}
|
||||
@ -0,0 +1,386 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.util.sortAlphabetically
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.util.toAuthenticatorItems
|
||||
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat
|
||||
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
|
||||
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
||||
import com.bitwarden.authenticator.data.platform.repository.model.DataState
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.map
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportVaultFormat
|
||||
import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the
|
||||
* specified period of time after it no longer has subscribers.
|
||||
*/
|
||||
private const val STOP_TIMEOUT_DELAY_MS: Long = 5_000L
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthenticatorRepository].
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class AuthenticatorRepositoryImpl @Inject constructor(
|
||||
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
|
||||
private val authenticatorDiskSource: AuthenticatorDiskSource,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val totpCodeManager: TotpCodeManager,
|
||||
private val fileManager: FileManager,
|
||||
private val importManager: ImportManager,
|
||||
private val settingRepository: SettingsRepository,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthenticatorRepository {
|
||||
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
private val mutableCiphersStateFlow =
|
||||
MutableStateFlow<DataState<List<AuthenticatorItemEntity>>>(DataState.Loading)
|
||||
|
||||
private val mutableTotpCodeResultFlow =
|
||||
bufferedMutableSharedFlow<TotpCodeResult>()
|
||||
|
||||
private val firstTimeAccountSyncChannel: Channel<Unit> =
|
||||
Channel(capacity = Channel.UNLIMITED)
|
||||
|
||||
override val totpCodeFlow: Flow<TotpCodeResult>
|
||||
get() = mutableTotpCodeResultFlow.asSharedFlow()
|
||||
|
||||
private val authenticatorDataFlow: StateFlow<DataState<AuthenticatorData>> =
|
||||
ciphersStateFlow.map { cipherDataState ->
|
||||
when (cipherDataState) {
|
||||
is DataState.Error -> {
|
||||
DataState.Error(
|
||||
cipherDataState.error,
|
||||
AuthenticatorData(cipherDataState.data.orEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
is DataState.Loaded -> {
|
||||
DataState.Loaded(AuthenticatorData(items = cipherDataState.data))
|
||||
}
|
||||
|
||||
DataState.Loading -> {
|
||||
DataState.Loading
|
||||
}
|
||||
|
||||
is DataState.NoNetwork -> {
|
||||
DataState.NoNetwork(AuthenticatorData(items = cipherDataState.data.orEmpty()))
|
||||
}
|
||||
|
||||
is DataState.Pending -> {
|
||||
DataState.Pending(AuthenticatorData(items = cipherDataState.data))
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = STOP_TIMEOUT_DELAY_MS),
|
||||
initialValue = DataState.Loading,
|
||||
)
|
||||
|
||||
override val ciphersStateFlow: StateFlow<DataState<List<AuthenticatorItemEntity>>>
|
||||
get() = mutableCiphersStateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
authenticatorDiskSource
|
||||
.getItems()
|
||||
.onStart {
|
||||
mutableCiphersStateFlow.value = DataState.Loading
|
||||
}
|
||||
.onEach {
|
||||
mutableCiphersStateFlow.value = DataState.Loaded(it.sortAlphabetically())
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
|
||||
authenticatorBridgeManager
|
||||
.accountSyncStateFlow
|
||||
.onEach { emitFirstTimeSyncIfNeeded(it) }
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
override fun getItemStateFlow(itemId: String): StateFlow<DataState<AuthenticatorItemEntity?>> =
|
||||
authenticatorDataFlow
|
||||
.map { dataState ->
|
||||
dataState.map { authenticatorData ->
|
||||
authenticatorData
|
||||
.items
|
||||
.find { it.id == itemId }
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_DELAY_MS),
|
||||
initialValue = DataState.Loading,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val sharedCodesStateFlow: StateFlow<SharedVerificationCodesState> by lazy {
|
||||
if (!featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync)) {
|
||||
MutableStateFlow(SharedVerificationCodesState.FeatureNotEnabled)
|
||||
} else {
|
||||
authenticatorBridgeManager
|
||||
.accountSyncStateFlow
|
||||
.flatMapLatest { accountSyncState ->
|
||||
when (accountSyncState) {
|
||||
AccountSyncState.AppNotInstalled ->
|
||||
MutableStateFlow(SharedVerificationCodesState.AppNotInstalled)
|
||||
|
||||
AccountSyncState.SyncNotEnabled ->
|
||||
MutableStateFlow(SharedVerificationCodesState.SyncNotEnabled)
|
||||
|
||||
AccountSyncState.Error ->
|
||||
MutableStateFlow(SharedVerificationCodesState.Error)
|
||||
|
||||
AccountSyncState.Loading ->
|
||||
MutableStateFlow(SharedVerificationCodesState.Loading)
|
||||
|
||||
AccountSyncState.OsVersionNotSupported -> MutableStateFlow(
|
||||
SharedVerificationCodesState.OsVersionNotSupported,
|
||||
)
|
||||
|
||||
is AccountSyncState.Success -> {
|
||||
val verificationCodesList =
|
||||
accountSyncState.accounts.toAuthenticatorItems()
|
||||
totpCodeManager
|
||||
.getTotpCodesFlow(verificationCodesList)
|
||||
.map { SharedVerificationCodesState.Success(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = SharedVerificationCodesState.Loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getLocalVerificationCodesFlow(): StateFlow<DataState<List<VerificationCodeItem>>> {
|
||||
return authenticatorDataFlow
|
||||
.map { dataState ->
|
||||
dataState
|
||||
.map { authenticatorData ->
|
||||
authenticatorData.items
|
||||
.map { entity ->
|
||||
AuthenticatorItem(
|
||||
source = AuthenticatorItem.Source.Local(
|
||||
cipherId = entity.id,
|
||||
isFavorite = entity.favorite,
|
||||
),
|
||||
otpUri = entity.toOtpAuthUriString(),
|
||||
issuer = entity.issuer,
|
||||
label = entity.accountName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.flatMapLatest { authenticatorItems ->
|
||||
when (authenticatorItems) {
|
||||
is DataState.Error -> flowOf(DataState.Error(authenticatorItems.error))
|
||||
is DataState.NoNetwork -> flowOf(DataState.NoNetwork())
|
||||
DataState.Loading -> flowOf(DataState.Loading)
|
||||
is DataState.Loaded -> totpCodeManager.getTotpCodesFlow(authenticatorItems.data)
|
||||
.map { DataState.Loaded(it) }
|
||||
|
||||
is DataState.Pending -> totpCodeManager
|
||||
.getTotpCodesFlow(authenticatorItems.data)
|
||||
.map { DataState.Loaded(it) }
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_DELAY_MS),
|
||||
initialValue = DataState.Loading,
|
||||
)
|
||||
}
|
||||
|
||||
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
|
||||
mutableTotpCodeResultFlow.tryEmit(totpCodeResult)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun createItem(item: AuthenticatorItemEntity): CreateItemResult {
|
||||
return try {
|
||||
authenticatorDiskSource.saveItem(item)
|
||||
CreateItemResult.Success
|
||||
} catch (e: Exception) {
|
||||
CreateItemResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult {
|
||||
return try {
|
||||
authenticatorDiskSource.saveItem(*items)
|
||||
CreateItemResult.Success
|
||||
} catch (e: Exception) {
|
||||
CreateItemResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun hardDeleteItem(itemId: String): DeleteItemResult {
|
||||
return try {
|
||||
authenticatorDiskSource.deleteItem(itemId)
|
||||
DeleteItemResult.Success
|
||||
} catch (e: Exception) {
|
||||
DeleteItemResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportVaultData(
|
||||
format: ExportVaultFormat,
|
||||
fileUri: Uri,
|
||||
): ExportDataResult {
|
||||
return when (format) {
|
||||
ExportVaultFormat.JSON -> encodeVaultDataToJson(fileUri)
|
||||
ExportVaultFormat.CSV -> encodeVaultDataToCsv(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importVaultData(
|
||||
format: ImportFileFormat,
|
||||
fileData: IntentManager.FileData,
|
||||
): ImportDataResult = fileManager.uriToByteArray(fileData.uri)
|
||||
.map {
|
||||
importManager
|
||||
.import(
|
||||
importFileFormat = format,
|
||||
byteArray = it,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { ImportDataResult.Error() },
|
||||
)
|
||||
|
||||
override val firstTimeAccountSyncFlow: Flow<Unit>
|
||||
get() = firstTimeAccountSyncChannel.receiveAsFlow()
|
||||
|
||||
private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult {
|
||||
val headerLine =
|
||||
"folder,favorite,type,name,login_uri,login_totp"
|
||||
val dataLines = authenticatorDiskSource
|
||||
.getItems()
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
.joinToString("\n") { it.toCsvFormat() }
|
||||
|
||||
val csvString = "$headerLine\n$dataLines"
|
||||
|
||||
return if (fileManager.stringToUri(fileUri = fileUri, dataString = csvString)) {
|
||||
ExportDataResult.Success
|
||||
} else {
|
||||
ExportDataResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
private fun AuthenticatorItemEntity.toCsvFormat() =
|
||||
",,1,$issuer,,${toOtpAuthUriString()},$issuer,$period,$digits"
|
||||
|
||||
private suspend fun encodeVaultDataToJson(fileUri: Uri): ExportDataResult {
|
||||
val dataString: String = Json.encodeToString(
|
||||
ExportJsonData(
|
||||
encrypted = false,
|
||||
items = authenticatorDiskSource
|
||||
.getItems()
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
.map { it.toExportJsonItem() },
|
||||
),
|
||||
)
|
||||
|
||||
return if (
|
||||
fileManager.stringToUri(
|
||||
fileUri = fileUri,
|
||||
dataString = dataString,
|
||||
)
|
||||
) {
|
||||
ExportDataResult.Success
|
||||
} else {
|
||||
ExportDataResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
private fun AuthenticatorItemEntity.toExportJsonItem() = ExportJsonData.ExportItem(
|
||||
id = id,
|
||||
folderId = null,
|
||||
organizationId = null,
|
||||
collectionIds = null,
|
||||
name = issuer,
|
||||
notes = null,
|
||||
type = 1,
|
||||
login = ExportJsonData.ExportItem.ItemLoginData(
|
||||
totp = toOtpAuthUriString(),
|
||||
),
|
||||
favorite = false,
|
||||
)
|
||||
|
||||
private fun emitFirstTimeSyncIfNeeded(state: AccountSyncState) {
|
||||
when (state) {
|
||||
AccountSyncState.AppNotInstalled,
|
||||
AccountSyncState.Error,
|
||||
AccountSyncState.Loading,
|
||||
AccountSyncState.OsVersionNotSupported,
|
||||
AccountSyncState.SyncNotEnabled,
|
||||
-> Unit
|
||||
|
||||
is AccountSyncState.Success -> {
|
||||
val previouslySyncedAccounts = settingRepository.previouslySyncedBitwardenAccountIds
|
||||
val fistTimeSyncedAccounts = state
|
||||
.accounts
|
||||
.map { it.userId }
|
||||
.filterNot { previouslySyncedAccounts.contains(it) }
|
||||
// If there are fist time synced accounts, emit to the first time sync channel
|
||||
// and store the new account IDs:
|
||||
if (fistTimeSyncedAccounts.isNotEmpty()) {
|
||||
firstTimeAccountSyncChannel.trySend(Unit)
|
||||
settingRepository.previouslySyncedBitwardenAccountIds =
|
||||
previouslySyncedAccounts + fistTimeSyncedAccounts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.di
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.authenticator.BuildConfig
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.util.SymmetricKeyStorageProviderImpl
|
||||
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
|
||||
import com.bitwarden.authenticatorbridge.factory.AuthenticatorBridgeFactory
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
|
||||
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides repositories in the authenticator package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthenticatorBridgeModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorBridgeFactory(
|
||||
@ApplicationContext
|
||||
context: Context,
|
||||
): AuthenticatorBridgeFactory = AuthenticatorBridgeFactory(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorBridgeManager(
|
||||
factory: AuthenticatorBridgeFactory,
|
||||
symmetricKeyStorageProvider: SymmetricKeyStorageProvider,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
): AuthenticatorBridgeManager =
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.PasswordManagerSync)) {
|
||||
factory.getAuthenticatorBridgeManager(
|
||||
connectionType = BuildConfig.AUTHENTICATOR_BRIDGE_CONNECTION_TYPE,
|
||||
symmetricKeyStorageProvider = symmetricKeyStorageProvider,
|
||||
)
|
||||
} else {
|
||||
// If feature flag is not enabled, return no-op bridge manager so we never
|
||||
// connect to bridge service:
|
||||
object : AuthenticatorBridgeManager {
|
||||
override val accountSyncStateFlow: StateFlow<AccountSyncState>
|
||||
get() = MutableStateFlow(AccountSyncState.Loading)
|
||||
|
||||
override fun startAddTotpLoginItemFlow(totpUri: String): Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesSymmetricKeyStorageProvider(
|
||||
authDiskSource: AuthDiskSource,
|
||||
): SymmetricKeyStorageProvider =
|
||||
SymmetricKeyStorageProviderImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.di
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.FileManager
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepositoryImpl
|
||||
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
|
||||
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
|
||||
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides repositories in the authenticator package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthenticatorRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorRepository(
|
||||
authenticatorBridgeManager: AuthenticatorBridgeManager,
|
||||
authenticatorDiskSource: AuthenticatorDiskSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
fileManager: FileManager,
|
||||
importManager: ImportManager,
|
||||
totpCodeManager: TotpCodeManager,
|
||||
settingsRepository: SettingsRepository,
|
||||
): AuthenticatorRepository = AuthenticatorRepositoryImpl(
|
||||
authenticatorBridgeManager = authenticatorBridgeManager,
|
||||
authenticatorDiskSource = authenticatorDiskSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
fileManager = fileManager,
|
||||
importManager = importManager,
|
||||
totpCodeManager = totpCodeManager,
|
||||
settingRepository = settingsRepository,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
|
||||
/**
|
||||
* Represents decrypted authenticator data.
|
||||
*
|
||||
* @property items List of decrypted authenticator items.
|
||||
*/
|
||||
data class AuthenticatorData(
|
||||
val items: List<AuthenticatorItemEntity>,
|
||||
)
|
||||
@ -0,0 +1,51 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Represents all the information required to generate TOTP verification codes, including both
|
||||
* local codes and codes shared from the main Bitwarden app.
|
||||
*
|
||||
* @param source Distinguishes between local and shared items.
|
||||
* @param otpUri OTP URI.
|
||||
* @param issuer The issuer of the codes.
|
||||
* @param label The label of the item.
|
||||
*/
|
||||
data class AuthenticatorItem(
|
||||
val source: Source,
|
||||
val otpUri: String,
|
||||
val issuer: String?,
|
||||
val label: String?,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Contains data about where the source of truth for a [AuthenticatorItem] is.
|
||||
*/
|
||||
sealed class Source {
|
||||
|
||||
/**
|
||||
* The item is from the local Authenticator app database.
|
||||
*
|
||||
* @param cipherId Local cipher ID.
|
||||
* @param isFavorite Whether or not the user has marked the item as a favorite.
|
||||
*/
|
||||
data class Local(
|
||||
val cipherId: String,
|
||||
val isFavorite: Boolean,
|
||||
) : Source()
|
||||
|
||||
/**
|
||||
* The item is shared from the main Bitwarden app.
|
||||
*
|
||||
* @param userId User ID from the main Bitwarden app. Used to group authenticator items
|
||||
* by account.
|
||||
* @param nameOfUser Username from the main Bitwarden app.
|
||||
* @param email Email of the user.
|
||||
* @param environmentLabel Label for the Bitwarden environment, like "bitwaren.com"
|
||||
*/
|
||||
data class Shared(
|
||||
val userId: String,
|
||||
val nameOfUser: String?,
|
||||
val email: String,
|
||||
val environmentLabel: String,
|
||||
) : Source()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Models result of creating a cipher.
|
||||
*/
|
||||
sealed class CreateItemResult {
|
||||
|
||||
/**
|
||||
* Cipher created successfully.
|
||||
*/
|
||||
data object Success : CreateItemResult()
|
||||
|
||||
/**
|
||||
* Generic error while creating cipher.
|
||||
*/
|
||||
data object Error : CreateItemResult()
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Models result of deleting a cipher.
|
||||
*/
|
||||
sealed class DeleteItemResult {
|
||||
|
||||
/**
|
||||
* Cipher deleted successfully.
|
||||
*/
|
||||
data object Success : DeleteItemResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting a cipher.
|
||||
*/
|
||||
data object Error : DeleteItemResult()
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Represents the result of a data export operation.
|
||||
*/
|
||||
sealed class ExportDataResult {
|
||||
|
||||
/**
|
||||
* Data has been successfully exported.
|
||||
*/
|
||||
data object Success : ExportDataResult()
|
||||
|
||||
/**
|
||||
* Data could not be exported.
|
||||
*/
|
||||
data object Error : ExportDataResult()
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
|
||||
/**
|
||||
* Represents the state of verification codes shared from the main Bitwarden app.
|
||||
*/
|
||||
sealed class SharedVerificationCodesState {
|
||||
|
||||
/**
|
||||
* The Bitwarden app is not installed and therefore accounts cannot be synced.
|
||||
*/
|
||||
data object AppNotInstalled : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* Something went wrong syncing accounts.
|
||||
*/
|
||||
data object Error : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* The feature flag for authenticator sync is not enabled.
|
||||
*/
|
||||
data object FeatureNotEnabled : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* State is loading.
|
||||
*/
|
||||
data object Loading : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* OS version can't support account syncing.
|
||||
*/
|
||||
data object OsVersionNotSupported : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* Successfully synced items.
|
||||
*/
|
||||
data class Success(
|
||||
val items: List<VerificationCodeItem>,
|
||||
) : SharedVerificationCodesState()
|
||||
|
||||
/**
|
||||
* The user needs to enable authenticator syncing from the bitwarden app.
|
||||
*/
|
||||
data object SyncNotEnabled : SharedVerificationCodesState()
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Models result of the user adding a totp code.
|
||||
*/
|
||||
sealed class TotpCodeResult {
|
||||
|
||||
/**
|
||||
* Code containing an OTP URI has been successfully scanned.
|
||||
*/
|
||||
data class TotpCodeScan(val code: String) : TotpCodeResult()
|
||||
|
||||
/**
|
||||
* Code containing exported data from Google Authenticator was scanned.
|
||||
*/
|
||||
data class GoogleExportScan(val data: String) : TotpCodeResult()
|
||||
|
||||
/**
|
||||
* There was an error scanning the code.
|
||||
*/
|
||||
data object CodeScanningError : TotpCodeResult()
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
|
||||
|
||||
/**
|
||||
* Models a request to modify an existing authenticator item.
|
||||
*
|
||||
* @property key Key used to generate verification codes for the authenticator item.
|
||||
* @property accountName Required account or username associated with the item.
|
||||
* @property type Type of authenticator item.
|
||||
* @property algorithm Hashing algorithm applied to the authenticator item verification code.
|
||||
* @property period Time, in seconds, the authenticator item verification code is valid.
|
||||
* @property digits Number of digits contained in the verification code for this authenticator item.
|
||||
* @property issuer Entity that provided the authenticator item.
|
||||
*/
|
||||
data class UpdateItemRequest(
|
||||
val key: String,
|
||||
val accountName: String?,
|
||||
val type: AuthenticatorItemType,
|
||||
val algorithm: AuthenticatorItemAlgorithm,
|
||||
val period: Int,
|
||||
val digits: Int,
|
||||
val issuer: String,
|
||||
val favorite: Boolean,
|
||||
) {
|
||||
/**
|
||||
* The composite label of the authenticator item. Derived from combining [issuer] and [accountName]
|
||||
* ```
|
||||
* label = accountName /issuer (“:” / “%3A”) *”%20” accountName
|
||||
* ```
|
||||
*/
|
||||
val label = if (accountName.isNullOrBlank()) {
|
||||
issuer
|
||||
} else {
|
||||
"$issuer:$accountName"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.model
|
||||
|
||||
/**
|
||||
* Models result of updating an authenticator item.
|
||||
*/
|
||||
sealed class UpdateItemResult {
|
||||
|
||||
/**
|
||||
* Item updated successfully.
|
||||
*/
|
||||
data object Success : UpdateItemResult()
|
||||
|
||||
/**
|
||||
* Generic error while updating an item. The optional [errorMessage] may be displayed directly
|
||||
* in the UI when present.
|
||||
*/
|
||||
data class Error(val errorMessage: String?) : UpdateItemResult()
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.util
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
|
||||
import com.bitwarden.authenticator.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
|
||||
/**
|
||||
* Sorts the data in alphabetical order by name. Using lexicographical sorting but giving
|
||||
* precedence to special characters over letters and digits.
|
||||
*/
|
||||
@JvmName("toAlphabeticallySortedCipherList")
|
||||
fun List<AuthenticatorItemEntity>.sortAlphabetically(): List<AuthenticatorItemEntity> {
|
||||
return this.sortedWith(
|
||||
comparator = { cipher1, cipher2 ->
|
||||
SpecialCharWithPrecedenceComparator.compare(cipher1.issuer, cipher2.issuer)
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem
|
||||
import com.bitwarden.authenticatorbridge.model.SharedAccountData
|
||||
|
||||
/**
|
||||
* Convert a list of [SharedAccountData.Account] to a list of [AuthenticatorItem].
|
||||
*/
|
||||
fun List<SharedAccountData.Account>.toAuthenticatorItems(): List<AuthenticatorItem> =
|
||||
flatMap { sharedAccount ->
|
||||
sharedAccount.totpUris.mapNotNull { totpUriString ->
|
||||
runCatching {
|
||||
val uri = Uri.parse(totpUriString)
|
||||
val issuer = uri.getQueryParameter(TotpCodeManager.ISSUER_PARAM)
|
||||
val label = uri.pathSegments
|
||||
.firstOrNull()
|
||||
?.removePrefix("$issuer:")
|
||||
|
||||
AuthenticatorItem(
|
||||
source = AuthenticatorItem.Source.Shared(
|
||||
userId = sharedAccount.userId,
|
||||
nameOfUser = sharedAccount.name,
|
||||
email = sharedAccount.email,
|
||||
environmentLabel = sharedAccount.environmentLabel,
|
||||
),
|
||||
otpUri = totpUriString,
|
||||
issuer = issuer,
|
||||
label = label,
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.util
|
||||
|
||||
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||
|
||||
/**
|
||||
* Whether or not the user has enabled sync with Bitwarden and the two apps are successfully
|
||||
* syncing. This is useful to know when to show certain sync UI and also when to support
|
||||
* moving codes to Bitwarden.
|
||||
*/
|
||||
val SharedVerificationCodesState.isSyncWithBitwardenEnabled: Boolean
|
||||
get() = when (this) {
|
||||
SharedVerificationCodesState.AppNotInstalled,
|
||||
SharedVerificationCodesState.Error,
|
||||
SharedVerificationCodesState.FeatureNotEnabled,
|
||||
SharedVerificationCodesState.Loading,
|
||||
SharedVerificationCodesState.OsVersionNotSupported,
|
||||
SharedVerificationCodesState.SyncNotEnabled,
|
||||
-> false
|
||||
|
||||
is SharedVerificationCodesState.Success -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of shared items, or empty if there are no shared items.
|
||||
*/
|
||||
val SharedVerificationCodesState.itemsOrEmpty: List<VerificationCodeItem>
|
||||
get() = when (this) {
|
||||
SharedVerificationCodesState.AppNotInstalled,
|
||||
SharedVerificationCodesState.Error,
|
||||
SharedVerificationCodesState.FeatureNotEnabled,
|
||||
SharedVerificationCodesState.Loading,
|
||||
SharedVerificationCodesState.OsVersionNotSupported,
|
||||
SharedVerificationCodesState.SyncNotEnabled,
|
||||
-> emptyList()
|
||||
|
||||
is SharedVerificationCodesState.Success -> this.items
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitwarden.authenticator.data.authenticator.repository.util
|
||||
|
||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
|
||||
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
|
||||
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
|
||||
|
||||
/**
|
||||
* Implementation of [SymmetricKeyStorageProvider] that stores symmetric key data in encrypted
|
||||
* shared preferences.
|
||||
*/
|
||||
class SymmetricKeyStorageProviderImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
) : SymmetricKeyStorageProvider {
|
||||
|
||||
override var symmetricKey: SymmetricEncryptionKeyData?
|
||||
get() = authDiskSource.authenticatorBridgeSymmetricSyncKey?.toSymmetricEncryptionKeyData()
|
||||
set(value) {
|
||||
authDiskSource.authenticatorBridgeSymmetricSyncKey =
|
||||
value?.symmetricEncryptionKey?.byteArray
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.bitwarden.authenticator.data.platform.annotation
|
||||
|
||||
/**
|
||||
* Used to omit the annotated class from test coverage reporting. This should be used sparingly and
|
||||
* is intended for non-testable classes that are placed in packages along with testable ones.
|
||||
*/
|
||||
@Target(
|
||||
AnnotationTarget.CLASS,
|
||||
AnnotationTarget.FILE,
|
||||
AnnotationTarget.FUNCTION,
|
||||
)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class OmitFromCoverage
|
||||
@ -0,0 +1,11 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.di
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Used to denote an instance of [SharedPreferences] that encrypts its data.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class EncryptedPreferences
|
||||
@ -0,0 +1,48 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.di
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides dependencies related to encryption / decryption / secure generation.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PreferenceModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@UnencryptedPreferences
|
||||
fun provideUnencryptedSharedPreferences(
|
||||
application: Application,
|
||||
): SharedPreferences =
|
||||
application.getSharedPreferences(
|
||||
"${application.packageName}_preferences",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@EncryptedPreferences
|
||||
fun provideEncryptedSharedPreferences(
|
||||
application: Application,
|
||||
): SharedPreferences =
|
||||
EncryptedSharedPreferences
|
||||
.create(
|
||||
application,
|
||||
"${application.packageName}_encrypted_preferences",
|
||||
MasterKey.Builder(application)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.di
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Used to denote an instance of [SharedPreferences] that does not encrypt its data.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class UnencryptedPreferences
|
||||
@ -0,0 +1,140 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Base class for simplifying interactions with [SharedPreferences].
|
||||
*/
|
||||
@Suppress("UnnecessaryAbstractClass", "TooManyFunctions")
|
||||
abstract class BaseDiskSource(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
) {
|
||||
/**
|
||||
* Gets the [Boolean] for the given [key] from [SharedPreferences], or return the [default]
|
||||
* value if that key is not present.
|
||||
*/
|
||||
protected fun getBoolean(
|
||||
key: String,
|
||||
default: Boolean? = null,
|
||||
): Boolean? =
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the
|
||||
* value is `null`).
|
||||
*/
|
||||
protected fun putBoolean(
|
||||
key: String,
|
||||
value: Boolean?,
|
||||
): Unit =
|
||||
sharedPreferences.edit {
|
||||
if (value != null) {
|
||||
putBoolean(key, value)
|
||||
} else {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the [Int] for the given [key] from [SharedPreferences], or return the [default] value
|
||||
* if that key is not present.
|
||||
*/
|
||||
protected fun getInt(
|
||||
key: String,
|
||||
default: Int? = null,
|
||||
): Int? =
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getInt(key, 0)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the
|
||||
* value is `null`).
|
||||
*/
|
||||
protected fun putInt(
|
||||
key: String,
|
||||
value: Int?,
|
||||
): Unit =
|
||||
sharedPreferences.edit {
|
||||
if (value != null) {
|
||||
putInt(key, value)
|
||||
} else {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the [Long] for the given [key] from [SharedPreferences], or return the [default] value
|
||||
* if that key is not present.
|
||||
*/
|
||||
protected fun getLong(
|
||||
key: String,
|
||||
default: Long? = null,
|
||||
): Long? =
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getLong(key, 0)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the
|
||||
* value is `null`).
|
||||
*/
|
||||
protected fun putLong(
|
||||
key: String,
|
||||
value: Long?,
|
||||
): Unit =
|
||||
sharedPreferences.edit {
|
||||
if (value != null) {
|
||||
putLong(key, value)
|
||||
} else {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun getString(
|
||||
key: String,
|
||||
default: String? = null,
|
||||
): String? = sharedPreferences.getString(key, default)
|
||||
|
||||
protected fun putString(
|
||||
key: String,
|
||||
value: String?,
|
||||
): Unit = sharedPreferences.edit { putString(key, value) }
|
||||
|
||||
protected fun removeWithPrefix(prefix: String) {
|
||||
sharedPreferences
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(prefix) }
|
||||
.forEach { sharedPreferences.edit { remove(it) } }
|
||||
}
|
||||
|
||||
protected fun putStringSet(
|
||||
key: String,
|
||||
value: Set<String>?,
|
||||
): Unit = sharedPreferences.edit {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
|
||||
protected fun getStringSet(
|
||||
key: String,
|
||||
default: Set<String>?,
|
||||
): Set<String>? = sharedPreferences.getStringSet(key, default)
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
const val BASE_KEY: String = "bwPreferencesStorage"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
|
||||
/**
|
||||
* Base class for simplifying interactions with [SharedPreferences] and
|
||||
* [EncryptedSharedPreferences].
|
||||
*/
|
||||
@Suppress("UnnecessaryAbstractClass")
|
||||
abstract class BaseEncryptedDiskSource(
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val encryptedSharedPreferences: SharedPreferences,
|
||||
) : BaseDiskSource(
|
||||
sharedPreferences = sharedPreferences,
|
||||
) {
|
||||
protected fun getEncryptedString(
|
||||
key: String,
|
||||
default: String? = null,
|
||||
): String? = encryptedSharedPreferences.getString(key, default)
|
||||
|
||||
protected fun putEncryptedString(
|
||||
key: String,
|
||||
value: String?,
|
||||
): Unit = encryptedSharedPreferences.edit { putString(key, value) }
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
const val ENCRYPTED_BASE_KEY: String = "bwSecureStorage"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Primary access point for server configuration-related disk information.
|
||||
*/
|
||||
interface ConfigDiskSource {
|
||||
|
||||
/**
|
||||
* The currently persisted [ServerConfig] (or `null` if not set).
|
||||
*/
|
||||
var serverConfig: ServerConfig?
|
||||
|
||||
/**
|
||||
* Emits updates that track [ServerConfig]. This will replay the last known value,
|
||||
* if any.
|
||||
*/
|
||||
val serverConfigFlow: Flow<ServerConfig?>
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.authenticator.data.platform.util.decodeFromStringOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val SERVER_CONFIGURATIONS = "$BASE_KEY:serverConfigurations"
|
||||
|
||||
/**
|
||||
* Primary implementation of [ConfigDiskSource].
|
||||
*/
|
||||
class ConfigDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseDiskSource(sharedPreferences = sharedPreferences),
|
||||
ConfigDiskSource {
|
||||
|
||||
override var serverConfig: ServerConfig?
|
||||
get() = getString(key = SERVER_CONFIGURATIONS)?.let { json.decodeFromStringOrNull(it) }
|
||||
set(value) {
|
||||
putString(
|
||||
key = SERVER_CONFIGURATIONS,
|
||||
value = value?.let { json.encodeToString(it) },
|
||||
)
|
||||
mutableServerConfigFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val serverConfigFlow: Flow<ServerConfig?>
|
||||
get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) }
|
||||
|
||||
private val mutableServerConfigFlow = bufferedMutableSharedFlow<ServerConfig?>(replay = 1)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Primary access point for feature flag configuration.
|
||||
*/
|
||||
interface FeatureFlagDiskSource {
|
||||
|
||||
/**
|
||||
* The currently persisted [FeatureFlagsConfiguration].
|
||||
*/
|
||||
var featureFlagsConfiguration: FeatureFlagsConfiguration?
|
||||
|
||||
/**
|
||||
* Emits updates to track [FeatureFlagsConfiguration]. This will replay the last known value.
|
||||
*/
|
||||
val featureFlagsConfigurationFlow: Flow<FeatureFlagsConfiguration?>
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.model.FeatureFlagsConfiguration
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.authenticator.data.platform.util.decodeFromStringOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val KEY_FEATURE_FLAGS = "$BASE_KEY:featureFlags"
|
||||
|
||||
/**
|
||||
* Primary implementation of [FeatureFlagDiskSource].
|
||||
*/
|
||||
class FeatureFlagDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseDiskSource(sharedPreferences = sharedPreferences),
|
||||
FeatureFlagDiskSource {
|
||||
|
||||
private val mutableFeatureFlagsConfigurationFlow =
|
||||
bufferedMutableSharedFlow<FeatureFlagsConfiguration?>(replay = 1)
|
||||
|
||||
override val featureFlagsConfigurationFlow: Flow<FeatureFlagsConfiguration?>
|
||||
get() = mutableFeatureFlagsConfigurationFlow.onSubscription {
|
||||
emit(featureFlagsConfiguration)
|
||||
}
|
||||
|
||||
override var featureFlagsConfiguration: FeatureFlagsConfiguration?
|
||||
get() = getString(key = KEY_FEATURE_FLAGS)
|
||||
?.let { json.decodeFromStringOrNull(it) }
|
||||
set(value) {
|
||||
putString(
|
||||
key = KEY_FEATURE_FLAGS,
|
||||
value = value.let { json.encodeToString(it) },
|
||||
)
|
||||
mutableFeatureFlagsConfigurationFlow.tryEmit(value)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
|
||||
|
||||
/**
|
||||
* Disk data source for saved feature flag overrides.
|
||||
*/
|
||||
interface FeatureFlagOverrideDiskSource {
|
||||
|
||||
/**
|
||||
* Save a feature flag [FlagKey] to disk.
|
||||
*/
|
||||
fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T)
|
||||
|
||||
/**
|
||||
* Get a feature flag value based on the associated [FlagKey] from disk.
|
||||
*/
|
||||
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
|
||||
|
||||
/**
|
||||
* Default implementation of the [FeatureFlagOverrideDiskSource]
|
||||
*/
|
||||
class FeatureFlagOverrideDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
) : FeatureFlagOverrideDiskSource, BaseDiskSource(sharedPreferences) {
|
||||
|
||||
override fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T) {
|
||||
when (key.defaultValue) {
|
||||
is Boolean -> putBoolean(key.keyName, value as Boolean)
|
||||
is String -> putString(key.keyName, value as String)
|
||||
is Int -> putInt(key.keyName, value as Int)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T? {
|
||||
return try {
|
||||
when (key.defaultValue) {
|
||||
is Boolean -> getBoolean(key.keyName) as? T
|
||||
is String -> getString(key.keyName) as? T
|
||||
is Int -> getInt(key.keyName) as? T
|
||||
else -> null
|
||||
}
|
||||
} catch (castException: ClassCastException) {
|
||||
null
|
||||
} catch (numberFormatException: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Primary access point for general settings-related disk information.
|
||||
*/
|
||||
interface SettingsDiskSource {
|
||||
|
||||
/**
|
||||
* The currently persisted app language (or `null` if not set).
|
||||
*/
|
||||
var appLanguage: AppLanguage?
|
||||
|
||||
/**
|
||||
* The currently persisted app theme (or `null` if not set).
|
||||
*/
|
||||
var appTheme: AppTheme
|
||||
|
||||
/**
|
||||
* Emits updates that track [appTheme].
|
||||
*/
|
||||
val appThemeFlow: Flow<AppTheme>
|
||||
|
||||
/**
|
||||
* The currently persisted default save option.
|
||||
*/
|
||||
var defaultSaveOption: DefaultSaveOption
|
||||
|
||||
/**
|
||||
* Flow that emits changes to [defaultSaveOption]
|
||||
*/
|
||||
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
|
||||
|
||||
/**
|
||||
* The currently persisted biometric integrity source for the system.
|
||||
*/
|
||||
var systemBiometricIntegritySource: String?
|
||||
|
||||
/**
|
||||
* Tracks whether user has seen the Welcome tutorial.
|
||||
*/
|
||||
var hasSeenWelcomeTutorial: Boolean
|
||||
|
||||
/**
|
||||
* A set of Bitwarden account IDs that have previously been synced.
|
||||
*/
|
||||
var previouslySyncedBitwardenAccountIds: Set<String>
|
||||
|
||||
/**
|
||||
* Emits update that track [hasSeenWelcomeTutorial]
|
||||
*/
|
||||
val hasSeenWelcomeTutorialFlow: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* The current setting for if crash logging is enabled.
|
||||
*/
|
||||
var isCrashLoggingEnabled: Boolean?
|
||||
|
||||
/**
|
||||
* The current setting for if crash logging is enabled.
|
||||
*/
|
||||
val isCrashLoggingEnabledFlow: Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Whether or not the user has previously dismissed the download Bitwarden action card.
|
||||
*/
|
||||
var hasUserDismissedDownloadBitwardenCard: Boolean?
|
||||
|
||||
/**
|
||||
* Whether or not the user has previously dismissed the sync with Bitwarden action card.
|
||||
*/
|
||||
var hasUserDismissedSyncWithBitwardenCard: Boolean?
|
||||
|
||||
/**
|
||||
* Stores the threshold at which users are alerted that an items validity period is nearing
|
||||
* expiration.
|
||||
*/
|
||||
fun storeAlertThresholdSeconds(thresholdSeconds: Int)
|
||||
|
||||
/**
|
||||
* Gets the threshold at which users are alerted that an items validity period is nearing
|
||||
* expiration.
|
||||
*/
|
||||
fun getAlertThresholdSeconds(): Int
|
||||
|
||||
/**
|
||||
* Emits updates that track the threshold at which users are alerted that an items validity
|
||||
* period is nearing expiration.
|
||||
*/
|
||||
fun getAlertThresholdSecondsFlow(): Flow<Int>
|
||||
|
||||
/**
|
||||
* Retrieves the biometric integrity validity for the given [systemBioIntegrityState].
|
||||
*/
|
||||
fun getAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState: String,
|
||||
): Boolean?
|
||||
|
||||
/**
|
||||
* Stores the biometric integrity validity for the given [systemBioIntegrityState].
|
||||
*/
|
||||
fun storeAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState: String,
|
||||
value: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets whether or not the user has enabled screen capture.
|
||||
*/
|
||||
fun getScreenCaptureAllowed(): Boolean?
|
||||
|
||||
/**
|
||||
* Emits updates that track [getScreenCaptureAllowed].
|
||||
*/
|
||||
fun getScreenCaptureAllowedFlow(): Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Stores whether or not [isScreenCaptureAllowed].
|
||||
*/
|
||||
fun storeScreenCaptureAllowed(isScreenCaptureAllowed: Boolean?)
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
|
||||
private const val APP_THEME_KEY = "$BASE_KEY:theme"
|
||||
private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale"
|
||||
private const val DEFAULT_SAVE_OPTION_KEY = "$BASE_KEY:defaultSaveOption"
|
||||
private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "$BASE_KEY:biometricIntegritySource"
|
||||
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
|
||||
private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds"
|
||||
private const val FIRST_LAUNCH_KEY = "$BASE_KEY:hasSeenWelcomeTutorial"
|
||||
private const val CRASH_LOGGING_ENABLED_KEY = "$BASE_KEY:crashLoggingEnabled"
|
||||
private const val SCREEN_CAPTURE_ALLOW_KEY = "screenCaptureAllowed"
|
||||
private const val HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY =
|
||||
"$BASE_KEY:hasUserDismissedDownloadBitwardenCard"
|
||||
private const val HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY =
|
||||
"$BASE_KEY:hasUserDismissedSyncWithBitwardenCard"
|
||||
private const val PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY =
|
||||
"$BASE_KEY:previouslySyncedBitwardenAccountIds"
|
||||
private const val DEFAULT_ALERT_THRESHOLD_SECONDS = 7
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
*/
|
||||
class SettingsDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
) : BaseDiskSource(sharedPreferences = sharedPreferences),
|
||||
SettingsDiskSource {
|
||||
private val mutableAppThemeFlow =
|
||||
bufferedMutableSharedFlow<AppTheme>(replay = 1)
|
||||
|
||||
private val mutableScreenCaptureAllowedFlow =
|
||||
bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableAlertThresholdSecondsFlow =
|
||||
bufferedMutableSharedFlow<Int>()
|
||||
|
||||
private val mutableIsCrashLoggingEnabledFlow =
|
||||
bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableDefaultSaveOptionFlow =
|
||||
bufferedMutableSharedFlow<DefaultSaveOption>()
|
||||
|
||||
override var appLanguage: AppLanguage?
|
||||
get() = getString(key = APP_LANGUAGE_KEY)
|
||||
?.let { storedValue ->
|
||||
AppLanguage.entries.firstOrNull { storedValue == it.localeName }
|
||||
}
|
||||
set(value) {
|
||||
putString(
|
||||
key = APP_LANGUAGE_KEY,
|
||||
value = value?.localeName,
|
||||
)
|
||||
}
|
||||
|
||||
private val mutableFirstLaunchFlow =
|
||||
bufferedMutableSharedFlow<Boolean>()
|
||||
|
||||
override var appTheme: AppTheme
|
||||
get() = getString(key = APP_THEME_KEY)
|
||||
?.let { storedValue ->
|
||||
AppTheme.entries.firstOrNull { storedValue == it.value }
|
||||
}
|
||||
?: AppTheme.DEFAULT
|
||||
set(newValue) {
|
||||
putString(
|
||||
key = APP_THEME_KEY,
|
||||
value = newValue.value,
|
||||
)
|
||||
mutableAppThemeFlow.tryEmit(appTheme)
|
||||
}
|
||||
|
||||
override val appThemeFlow: Flow<AppTheme>
|
||||
get() = mutableAppThemeFlow
|
||||
.onSubscription { emit(appTheme) }
|
||||
|
||||
override var defaultSaveOption: DefaultSaveOption
|
||||
get() = getString(key = DEFAULT_SAVE_OPTION_KEY)
|
||||
?.let { storedValue ->
|
||||
DefaultSaveOption.entries.firstOrNull { storedValue == it.value }
|
||||
}
|
||||
?: DefaultSaveOption.NONE
|
||||
set(newValue) {
|
||||
putString(
|
||||
key = DEFAULT_SAVE_OPTION_KEY,
|
||||
value = newValue.value,
|
||||
)
|
||||
mutableDefaultSaveOptionFlow.tryEmit(newValue)
|
||||
}
|
||||
override val defaultSaveOptionFlow: Flow<DefaultSaveOption>
|
||||
get() = mutableDefaultSaveOptionFlow
|
||||
.onSubscription { emit(defaultSaveOption) }
|
||||
|
||||
override var systemBiometricIntegritySource: String?
|
||||
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
|
||||
set(value) {
|
||||
putString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY, value = value)
|
||||
}
|
||||
|
||||
override var hasSeenWelcomeTutorial: Boolean
|
||||
get() = getBoolean(key = FIRST_LAUNCH_KEY) ?: false
|
||||
set(value) {
|
||||
putBoolean(key = FIRST_LAUNCH_KEY, value)
|
||||
mutableFirstLaunchFlow.tryEmit(hasSeenWelcomeTutorial)
|
||||
}
|
||||
|
||||
override var previouslySyncedBitwardenAccountIds: Set<String>
|
||||
get() = getStringSet(
|
||||
key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY,
|
||||
default = emptySet(),
|
||||
) ?: emptySet()
|
||||
set(value) {
|
||||
putStringSet(
|
||||
key = PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY,
|
||||
value = value,
|
||||
)
|
||||
}
|
||||
|
||||
override val hasSeenWelcomeTutorialFlow: Flow<Boolean>
|
||||
get() = mutableFirstLaunchFlow.onSubscription { emit(hasSeenWelcomeTutorial) }
|
||||
|
||||
override var isCrashLoggingEnabled: Boolean?
|
||||
get() = getBoolean(key = CRASH_LOGGING_ENABLED_KEY)
|
||||
set(value) {
|
||||
putBoolean(key = CRASH_LOGGING_ENABLED_KEY, value = value)
|
||||
mutableIsCrashLoggingEnabledFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val isCrashLoggingEnabledFlow: Flow<Boolean?>
|
||||
get() = mutableIsCrashLoggingEnabledFlow
|
||||
.onSubscription { emit(getBoolean(CRASH_LOGGING_ENABLED_KEY)) }
|
||||
|
||||
override var hasUserDismissedDownloadBitwardenCard: Boolean?
|
||||
get() = getBoolean(HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY, null)
|
||||
set(value) {
|
||||
putBoolean(HAS_USER_DISMISSED_DOWNLOAD_BITWARDEN_KEY, value)
|
||||
}
|
||||
|
||||
override var hasUserDismissedSyncWithBitwardenCard: Boolean?
|
||||
get() = getBoolean(HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY, null)
|
||||
set(value) {
|
||||
putBoolean(HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY, value)
|
||||
}
|
||||
|
||||
override fun storeAlertThresholdSeconds(thresholdSeconds: Int) {
|
||||
putInt(
|
||||
ALERT_THRESHOLD_SECONDS_KEY,
|
||||
thresholdSeconds,
|
||||
)
|
||||
mutableAlertThresholdSecondsFlow.tryEmit(thresholdSeconds)
|
||||
}
|
||||
|
||||
override fun getAlertThresholdSeconds() =
|
||||
getInt(ALERT_THRESHOLD_SECONDS_KEY, default = DEFAULT_ALERT_THRESHOLD_SECONDS)
|
||||
?: DEFAULT_ALERT_THRESHOLD_SECONDS
|
||||
|
||||
override fun getAlertThresholdSecondsFlow(): Flow<Int> = mutableAlertThresholdSecondsFlow
|
||||
.onSubscription { emit(getAlertThresholdSeconds()) }
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState: String,
|
||||
): Boolean? =
|
||||
getBoolean(
|
||||
key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$systemBioIntegrityState",
|
||||
)
|
||||
|
||||
override fun storeAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState: String,
|
||||
value: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$systemBioIntegrityState",
|
||||
value = value,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getScreenCaptureAllowed(): Boolean? {
|
||||
return getBoolean(key = SCREEN_CAPTURE_ALLOW_KEY)
|
||||
}
|
||||
|
||||
override fun getScreenCaptureAllowedFlow(): Flow<Boolean?> = mutableScreenCaptureAllowedFlow
|
||||
.onSubscription { emit(getScreenCaptureAllowed()) }
|
||||
|
||||
override fun storeScreenCaptureAllowed(
|
||||
isScreenCaptureAllowed: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = SCREEN_CAPTURE_ALLOW_KEY,
|
||||
value = isScreenCaptureAllowed,
|
||||
)
|
||||
mutableScreenCaptureAllowedFlow.tryEmit(isScreenCaptureAllowed)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk.di
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.di.UnencryptedPreferences
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSourceImpl
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSourceImpl
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSourceImpl
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSourceImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
/**
|
||||
* Provides persistence-related dependencies in the platform package.
|
||||
*/
|
||||
object PlatformDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideConfigDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ConfigDiskSource =
|
||||
ConfigDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
): SettingsDiskSource =
|
||||
SettingsDiskSourceImpl(sharedPreferences = sharedPreferences)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeatureFlagDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): FeatureFlagDiskSource =
|
||||
FeatureFlagDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeatureFlagOverrideDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Models the state of feature flags.
|
||||
*/
|
||||
@Serializable
|
||||
data class FeatureFlagsConfiguration(
|
||||
@SerialName("featureFlags")
|
||||
val featureFlags: Map<String, JsonPrimitive>,
|
||||
)
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.disk.model
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A higher-level wrapper around [ConfigResponseJson] that provides a timestamp
|
||||
* to check if a sync is necessary
|
||||
*
|
||||
* @property lastSync The [Long] of the last sync.
|
||||
* @property serverData The raw [ConfigResponseJson] that contains specific data of the
|
||||
* server configuration
|
||||
*/
|
||||
@Serializable
|
||||
data class ServerConfig(
|
||||
@SerialName("lastSync")
|
||||
val lastSync: Long,
|
||||
|
||||
@SerialName("serverData")
|
||||
val serverData: ConfigResponseJson,
|
||||
)
|
||||
@ -0,0 +1,13 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.api
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* This interface defines the API service for fetching configuration data.
|
||||
*/
|
||||
interface ConfigApi {
|
||||
|
||||
@GET("config")
|
||||
suspend fun getConfig(): Result<ConfigResponseJson>
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.core
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage
|
||||
import com.bitwarden.authenticator.data.platform.util.asFailure
|
||||
import com.bitwarden.authenticator.data.platform.util.asSuccess
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
import okio.Timeout
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* The integer code value for a "No Content" response.
|
||||
*/
|
||||
private const val NO_CONTENT_RESPONSE_CODE: Int = 204
|
||||
|
||||
/**
|
||||
* A [Call] for wrapping a network request into a [Result].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class ResultCall<T>(
|
||||
private val backingCall: Call<T>,
|
||||
private val successType: Type,
|
||||
) : Call<Result<T>> {
|
||||
override fun cancel(): Unit = backingCall.cancel()
|
||||
|
||||
override fun clone(): Call<Result<T>> = ResultCall(backingCall, successType)
|
||||
|
||||
override fun enqueue(callback: Callback<Result<T>>): Unit = backingCall.enqueue(
|
||||
object : Callback<T> {
|
||||
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||
callback.onResponse(this@ResultCall, Response.success(response.toResult()))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||
callback.onResponse(this@ResultCall, Response.success(t.toFailure()))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun execute(): Response<Result<T>> =
|
||||
try {
|
||||
Response.success(
|
||||
backingCall
|
||||
.execute()
|
||||
.toResult(),
|
||||
)
|
||||
} catch (ioException: IOException) {
|
||||
Response.success(ioException.toFailure())
|
||||
} catch (runtimeException: RuntimeException) {
|
||||
Response.success(runtimeException.toFailure())
|
||||
}
|
||||
|
||||
override fun isCanceled(): Boolean = backingCall.isCanceled
|
||||
|
||||
override fun isExecuted(): Boolean = backingCall.isExecuted
|
||||
|
||||
override fun request(): Request = backingCall.request()
|
||||
|
||||
override fun timeout(): Timeout = backingCall.timeout()
|
||||
|
||||
/**
|
||||
* Synchronously send the request and return its response as a [Result].
|
||||
*/
|
||||
fun executeForResult(): Result<T> = requireNotNull(execute().body())
|
||||
|
||||
private fun Throwable.toFailure(): Result<T> =
|
||||
this
|
||||
.also {
|
||||
// We rebuild the URL without query params, we do not want to log those
|
||||
val url = backingCall.request().url.toUrl().run { "$protocol://$authority$path" }
|
||||
}
|
||||
.asFailure()
|
||||
|
||||
private fun Response<T>.toResult(): Result<T> =
|
||||
if (!this.isSuccessful) {
|
||||
HttpException(this).toFailure()
|
||||
} else {
|
||||
val body = this.body()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
when {
|
||||
// We got a nonnull T as the body, just return it.
|
||||
body != null -> body.asSuccess()
|
||||
// We expected the body to be null since the successType is Unit, just return Unit.
|
||||
successType == Unit::class.java -> (Unit as T).asSuccess()
|
||||
// We allow null for 204's, just return null.
|
||||
this.code() == NO_CONTENT_RESPONSE_CODE -> (null as T).asSuccess()
|
||||
// All other null bodies result in an error.
|
||||
else -> IllegalStateException("Unexpected null body!").toFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.core
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.CallAdapter
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* A [CallAdapter] for wrapping network requests into [kotlin.Result].
|
||||
*/
|
||||
class ResultCallAdapter<T>(
|
||||
private val successType: Type,
|
||||
) : CallAdapter<T, Call<Result<T>>> {
|
||||
|
||||
override fun responseType(): Type = successType
|
||||
override fun adapt(call: Call<T>): Call<Result<T>> = ResultCall(call, successType)
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.core
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.CallAdapter
|
||||
import retrofit2.Retrofit
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* A [CallAdapter.Factory] for wrapping network requests into [kotlin.Result].
|
||||
*/
|
||||
class ResultCallAdapterFactory : CallAdapter.Factory() {
|
||||
override fun get(
|
||||
returnType: Type,
|
||||
annotations: Array<out Annotation>,
|
||||
retrofit: Retrofit,
|
||||
): CallAdapter<*, *>? {
|
||||
check(returnType is ParameterizedType) { "$returnType must be parameterized" }
|
||||
val containerType = getParameterUpperBound(0, returnType)
|
||||
|
||||
if (getRawType(containerType) != Result::class.java) return null
|
||||
check(containerType is ParameterizedType) { "$containerType must be parameterized" }
|
||||
|
||||
val requestType = getParameterUpperBound(0, containerType)
|
||||
|
||||
return if (getRawType(returnType) == Call::class.java) {
|
||||
ResultCallAdapter<Any>(successType = requestType)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.di
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.HeadersInterceptor
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.Retrofits
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.RetrofitsImpl
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.serializer.ZonedDateTimeSerializer
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigServiceImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.contextual
|
||||
import retrofit2.create
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
/**
|
||||
* This class provides network-related functionality for the application.
|
||||
* It initializes and configures the networking components.
|
||||
*/
|
||||
object PlatformNetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesConfigService(
|
||||
retrofits: Retrofits,
|
||||
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofits(
|
||||
baseUrlInterceptors: BaseUrlInterceptors,
|
||||
headersInterceptor: HeadersInterceptor,
|
||||
json: Json,
|
||||
): Retrofits =
|
||||
RetrofitsImpl(
|
||||
baseUrlInterceptors = baseUrlInterceptors,
|
||||
headersInterceptor = headersInterceptor,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesJson(): Json = Json {
|
||||
|
||||
// If there are keys returned by the server not modeled by a serializable class,
|
||||
// ignore them.
|
||||
// This makes additive server changes non-breaking.
|
||||
ignoreUnknownKeys = true
|
||||
|
||||
// We allow for nullable values to have keys missing in the JSON response.
|
||||
explicitNulls = false
|
||||
serializersModule = SerializersModule {
|
||||
contextual(ZonedDateTimeSerializer())
|
||||
}
|
||||
|
||||
// Respect model default property values.
|
||||
coerceInputValues = true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.interceptor
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* A [Interceptor] that optionally takes the current base URL of a request and replaces it with
|
||||
* the currently set [baseUrl]
|
||||
*/
|
||||
class BaseUrlInterceptor : Interceptor {
|
||||
|
||||
/**
|
||||
* The base URL to use as an override, or `null` if no override should be performed.
|
||||
*/
|
||||
var baseUrl: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
baseHttpUrl = baseUrl?.let { requireNotNull(it.toHttpUrlOrNull()) }
|
||||
}
|
||||
|
||||
private var baseHttpUrl: HttpUrl? = null
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
// If no base URL is set, we can simply skip
|
||||
val base = baseHttpUrl ?: return chain.proceed(request)
|
||||
|
||||
// Update the base URL used.
|
||||
return chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.url(
|
||||
request
|
||||
.url
|
||||
.replaceBaseUrlWith(base),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a [HttpUrl], replaces the existing base URL with the given [baseUrl].
|
||||
*/
|
||||
private fun HttpUrl.replaceBaseUrlWith(
|
||||
baseUrl: HttpUrl,
|
||||
) = baseUrl
|
||||
.newBuilder()
|
||||
.addEncodedPathSegments(
|
||||
this
|
||||
.encodedPathSegments
|
||||
.joinToString(separator = "/"),
|
||||
)
|
||||
.encodedQuery(this.encodedQuery)
|
||||
.build()
|
||||
@ -0,0 +1,33 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.interceptor
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.repository.model.Environment
|
||||
import com.bitwarden.authenticator.data.platform.repository.util.baseApiUrl
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* An overall container for various [BaseUrlInterceptor] implementations for different API groups.
|
||||
*/
|
||||
@Singleton
|
||||
class BaseUrlInterceptors @Inject constructor() {
|
||||
var environment: Environment = Environment.Us
|
||||
set(value) {
|
||||
field = value
|
||||
updateBaseUrls(environment = value)
|
||||
}
|
||||
|
||||
/**
|
||||
* An interceptor for "/api" calls.
|
||||
*/
|
||||
val apiInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
|
||||
|
||||
init {
|
||||
// Ensure all interceptors begin with a default value
|
||||
environment = Environment.Us
|
||||
}
|
||||
|
||||
private fun updateBaseUrls(environment: Environment) {
|
||||
val environmentUrlData = environment.environmentUrlData
|
||||
apiInterceptor.baseUrl = environmentUrlData.baseApiUrl
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.interceptor
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_CLIENT_NAME
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_CLIENT_VERSION
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_DEVICE_TYPE
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_KEY_USER_AGENT
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_NAME
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_DEVICE_TYPE
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Interceptor responsible for adding various headers to all API requests.
|
||||
*/
|
||||
class HeadersInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(
|
||||
chain.request()
|
||||
.newBuilder()
|
||||
.header(HEADER_KEY_USER_AGENT, HEADER_VALUE_USER_AGENT)
|
||||
.header(HEADER_KEY_CLIENT_NAME, HEADER_VALUE_CLIENT_NAME)
|
||||
.header(HEADER_KEY_CLIENT_VERSION, HEADER_VALUE_CLIENT_VERSION)
|
||||
.header(HEADER_KEY_DEVICE_TYPE, HEADER_VALUE_DEVICE_TYPE)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Represents the response model for configuration data fetched from the server.
|
||||
*
|
||||
* @property type The object type, typically "config".
|
||||
* @property version The version of the configuration data.
|
||||
* @property gitHash The Git hash associated with the configuration data.
|
||||
* @property server The server information (nullable).
|
||||
* @property environment The environment information containing URLs (vault, api, identity, etc.).
|
||||
* @property featureStates A map containing various feature states.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConfigResponseJson(
|
||||
@SerialName("object")
|
||||
val type: String?,
|
||||
|
||||
@SerialName("version")
|
||||
val version: String?,
|
||||
|
||||
@SerialName("gitHash")
|
||||
val gitHash: String?,
|
||||
|
||||
@SerialName("server")
|
||||
val server: ServerJson?,
|
||||
|
||||
@SerialName("environment")
|
||||
val environment: EnvironmentJson?,
|
||||
|
||||
@SerialName("featureStates")
|
||||
val featureStates: Map<String, JsonPrimitive>?,
|
||||
) {
|
||||
/**
|
||||
* Represents a server in the configuration response.
|
||||
*
|
||||
* @param name The name of the server.
|
||||
* @param url The URL of the server.
|
||||
*/
|
||||
@Serializable
|
||||
data class ServerJson(
|
||||
@SerialName("name")
|
||||
val name: String?,
|
||||
|
||||
@SerialName("url")
|
||||
val url: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the environment details in the configuration response.
|
||||
*
|
||||
* @param cloudRegion The cloud region associated with the environment.
|
||||
* @param vaultUrl The URL of the vault service in the environment.
|
||||
* @param apiUrl The URL of the API service in the environment.
|
||||
* @param identityUrl The URL of the identity service in the environment.
|
||||
* @param notificationsUrl The URL of the notifications service in the environment.
|
||||
* @param ssoUrl The URL of the single sign-on (SSO) service in the environment.
|
||||
*/
|
||||
@Serializable
|
||||
data class EnvironmentJson(
|
||||
@SerialName("cloudRegion")
|
||||
val cloudRegion: String?,
|
||||
|
||||
@SerialName("vault")
|
||||
val vaultUrl: String?,
|
||||
|
||||
@SerialName("api")
|
||||
val apiUrl: String?,
|
||||
|
||||
@SerialName("identity")
|
||||
val identityUrl: String?,
|
||||
|
||||
@SerialName("notifications")
|
||||
val notificationsUrl: String?,
|
||||
|
||||
@SerialName("sso")
|
||||
val ssoUrl: String?,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.retrofit
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* A collection of various [Retrofit] instances that serve different purposes.
|
||||
*/
|
||||
interface Retrofits {
|
||||
|
||||
/**
|
||||
* Allows access to "/api" calls that do not require authentication.
|
||||
*
|
||||
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
|
||||
*/
|
||||
val unauthenticatedApiRetrofit: Retrofit
|
||||
|
||||
/**
|
||||
* Allows access to static API calls (ex: external APIs).
|
||||
*
|
||||
* @param isAuthenticated Indicates if the [Retrofit] instance should use authentication.
|
||||
* @param baseUrl The static base url associated with this retrofit instance. This can be
|
||||
* overridden with the [Url] annotation.
|
||||
*/
|
||||
fun createStaticRetrofit(
|
||||
isAuthenticated: Boolean = false,
|
||||
baseUrl: String = "https://api.bitwarden.com",
|
||||
): Retrofit
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.retrofit
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.core.ResultCallAdapterFactory
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptor
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.interceptor.HeadersInterceptor
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
|
||||
/**
|
||||
* Primary implementation of [Retrofits].
|
||||
*/
|
||||
class RetrofitsImpl(
|
||||
baseUrlInterceptors: BaseUrlInterceptors,
|
||||
headersInterceptor: HeadersInterceptor,
|
||||
json: Json,
|
||||
) : Retrofits {
|
||||
|
||||
//region Unauthenticated Retrofits
|
||||
|
||||
override val unauthenticatedApiRetrofit: Retrofit by lazy {
|
||||
createUnauthenticatedRetrofit(
|
||||
baseUrlInterceptor = baseUrlInterceptors.apiInterceptor,
|
||||
)
|
||||
}
|
||||
|
||||
//endregion Unauthenticated Retrofits
|
||||
|
||||
//region Static Retrofit
|
||||
|
||||
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {
|
||||
return baseRetrofitBuilder
|
||||
.baseUrl(baseUrl)
|
||||
.client(
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
//endregion Static Retrofit
|
||||
|
||||
//region Helper properties and functions
|
||||
|
||||
private val baseOkHttpClient: OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(headersInterceptor)
|
||||
.build()
|
||||
|
||||
private val baseRetrofit: Retrofit by lazy {
|
||||
baseRetrofitBuilder
|
||||
.baseUrl("https://api.bitwarden.com")
|
||||
.build()
|
||||
}
|
||||
|
||||
private val baseRetrofitBuilder: Retrofit.Builder by lazy {
|
||||
Retrofit.Builder()
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.addCallAdapterFactory(ResultCallAdapterFactory())
|
||||
.client(baseOkHttpClient)
|
||||
}
|
||||
|
||||
private fun createUnauthenticatedRetrofit(
|
||||
baseUrlInterceptor: BaseUrlInterceptor,
|
||||
): Retrofit =
|
||||
baseRetrofit
|
||||
.newBuilder()
|
||||
.client(
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(baseUrlInterceptor)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
//endregion Helper properties and functions
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.serializer
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Used to serialize and deserialize [ZonedDateTime].
|
||||
*/
|
||||
class ZonedDateTimeSerializer : KSerializer<ZonedDateTime> {
|
||||
private val dateTimeFormatterDeserialization = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.][:][SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]X")
|
||||
|
||||
private val dateTimeFormatterSerialization =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX")
|
||||
|
||||
override val descriptor: SerialDescriptor
|
||||
get() = PrimitiveSerialDescriptor(serialName = "ZonedDateTime", kind = PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): ZonedDateTime =
|
||||
decoder.decodeString().let { dateString ->
|
||||
ZonedDateTime.parse(dateString, dateTimeFormatterDeserialization)
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ZonedDateTime) {
|
||||
encoder.encodeString(dateTimeFormatterSerialization.format(value))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.service
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson
|
||||
|
||||
/**
|
||||
* Provides an API for querying for app configurations.
|
||||
*/
|
||||
interface ConfigService {
|
||||
|
||||
/**
|
||||
* Fetch app configuration.
|
||||
*/
|
||||
suspend fun getConfig(): Result<ConfigResponseJson>
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.service
|
||||
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.api.ConfigApi
|
||||
import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson
|
||||
|
||||
/**
|
||||
* Default implementation of [ConfigService] for querying for app configurations.
|
||||
*/
|
||||
class ConfigServiceImpl(private val configApi: ConfigApi) : ConfigService {
|
||||
override suspend fun getConfig(): Result<ConfigResponseJson> = configApi.getConfig()
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.bitwarden.authenticator.data.platform.datasource.network.util
|
||||
|
||||
import android.os.Build
|
||||
import com.bitwarden.authenticator.BuildConfig
|
||||
|
||||
/**
|
||||
* The bearer prefix used for the 'authorization' headers value.
|
||||
*/
|
||||
const val HEADER_VALUE_BEARER_PREFIX: String = "Bearer "
|
||||
|
||||
/**
|
||||
* The key used for the 'authorization' headers.
|
||||
*/
|
||||
const val HEADER_KEY_AUTHORIZATION: String = "Authorization"
|
||||
|
||||
/**
|
||||
* The key used for the 'user-agent' headers.
|
||||
*/
|
||||
const val HEADER_KEY_USER_AGENT: String = "User-Agent"
|
||||
|
||||
/**
|
||||
* The value used for the 'user-agent' headers.
|
||||
*/
|
||||
@Suppress("MaxLineLength")
|
||||
val HEADER_VALUE_USER_AGENT: String =
|
||||
"Bitwarden_Mobile/${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}) (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; Model ${Build.MODEL})"
|
||||
|
||||
/**
|
||||
* The key used for the 'bitwarden-client-name' headers.
|
||||
*/
|
||||
const val HEADER_KEY_CLIENT_NAME: String = "Bitwarden-Client-Name"
|
||||
|
||||
/**
|
||||
* The value used for the 'bitwarden-client-name' headers.
|
||||
*/
|
||||
const val HEADER_VALUE_CLIENT_NAME: String = "mobile"
|
||||
|
||||
/**
|
||||
* The key used for the 'bitwarden-client-version' headers.
|
||||
*/
|
||||
const val HEADER_KEY_CLIENT_VERSION: String = "Bitwarden-Client-Version"
|
||||
|
||||
/**
|
||||
* The value used for the 'bitwarden-client-version' headers.
|
||||
*/
|
||||
const val HEADER_VALUE_CLIENT_VERSION: String = BuildConfig.VERSION_NAME
|
||||
|
||||
/**
|
||||
* The key used for the 'device-type' headers.
|
||||
*/
|
||||
const val HEADER_KEY_DEVICE_TYPE: String = "Device-Type"
|
||||
|
||||
/**
|
||||
* The value used for the 'device-type' headers.
|
||||
*/
|
||||
const val HEADER_VALUE_DEVICE_TYPE: String = "0"
|
||||
@ -0,0 +1,19 @@
|
||||
package com.bitwarden.authenticator.data.platform.manager
|
||||
|
||||
/**
|
||||
* Responsible for managing Android keystore encryption and decryption.
|
||||
*/
|
||||
interface BiometricsEncryptionManager {
|
||||
/**
|
||||
* Sets up biometrics to ensure future integrity checks work properly. If this method has never
|
||||
* been called [isBiometricIntegrityValid] will return false.
|
||||
*/
|
||||
fun setupBiometrics()
|
||||
|
||||
/**
|
||||
* Checks to verify that the biometrics integrity is still valid. This returns `true` if the
|
||||
* biometrics data has not change since the app setup biometrics, `false` will be returned if
|
||||
* it has changed.
|
||||
*/
|
||||
fun isBiometricIntegrityValid(): Boolean
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package com.bitwarden.authenticator.data.platform.manager
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import com.bitwarden.authenticator.BuildConfig
|
||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.KeyStore
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
|
||||
/**
|
||||
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
|
||||
* and decryption.
|
||||
*/
|
||||
class BiometricsEncryptionManagerImpl(
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
) : BiometricsEncryptionManager {
|
||||
private val keystore = KeyStore
|
||||
.getInstance(ENCRYPTION_KEYSTORE_NAME)
|
||||
.also { it.load(null) }
|
||||
|
||||
private val keyGenParameterSpec: KeyGenParameterSpec
|
||||
get() = KeyGenParameterSpec
|
||||
.Builder(
|
||||
ENCRYPTION_KEY_NAME,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
|
||||
.setUserAuthenticationRequired(true)
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
.build()
|
||||
|
||||
override fun setupBiometrics() {
|
||||
createIntegrityValues()
|
||||
}
|
||||
|
||||
override fun isBiometricIntegrityValid(): Boolean =
|
||||
isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid()
|
||||
|
||||
private fun isAccountBiometricIntegrityValid(): Boolean {
|
||||
val systemBioIntegrityState = settingsDiskSource
|
||||
.systemBiometricIntegritySource
|
||||
?: return false
|
||||
return settingsDiskSource
|
||||
.getAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState = systemBioIntegrityState,
|
||||
)
|
||||
?: false
|
||||
}
|
||||
|
||||
private fun isSystemBiometricIntegrityValid(): Boolean =
|
||||
try {
|
||||
keystore.load(null)
|
||||
keystore
|
||||
.getKey(ENCRYPTION_KEY_NAME, null)
|
||||
?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) }
|
||||
true
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
// Biometric has changed
|
||||
settingsDiskSource.systemBiometricIntegritySource = null
|
||||
false
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
// Biometric was disabled and re-enabled
|
||||
settingsDiskSource.systemBiometricIntegritySource = null
|
||||
false
|
||||
} catch (e: InvalidKeyException) {
|
||||
// Fallback for old bitwarden users without a key
|
||||
createIntegrityValues()
|
||||
true
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun createIntegrityValues() {
|
||||
val systemBiometricIntegritySource = settingsDiskSource
|
||||
.systemBiometricIntegritySource
|
||||
?: UUID.randomUUID().toString()
|
||||
settingsDiskSource.systemBiometricIntegritySource = systemBiometricIntegritySource
|
||||
settingsDiskSource.storeAccountBiometricIntegrityValidity(
|
||||
systemBioIntegrityState = systemBiometricIntegritySource,
|
||||
value = true,
|
||||
)
|
||||
|
||||
try {
|
||||
val keyGen = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ENCRYPTION_KEYSTORE_NAME,
|
||||
)
|
||||
keyGen.init(keyGenParameterSpec)
|
||||
keyGen.generateKey()
|
||||
} catch (e: Exception) {
|
||||
// Catch silently to allow biometrics to function on devices that are in
|
||||
// a state where key generation is not functioning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ENCRYPTION_KEYSTORE_NAME: String = "AndroidKeyStore"
|
||||
private const val ENCRYPTION_KEY_NAME: String = "${BuildConfig.APPLICATION_ID}.biometric_integrity"
|
||||
private const val CIPHER_TRANSFORMATION =
|
||||
KeyProperties.KEY_ALGORITHM_AES + "/" +
|
||||
KeyProperties.BLOCK_MODE_CBC + "/" +
|
||||
KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitwarden.authenticator.data.platform.manager
|
||||
|
||||
/**
|
||||
* An interface for encoding and decoding data.
|
||||
*/
|
||||
interface BitwardenEncodingManager {
|
||||
|
||||
/**
|
||||
* Decodes '%'-escaped octets in the given string.
|
||||
*/
|
||||
fun uriDecode(value: String): String
|
||||
|
||||
/**
|
||||
* Decodes the specified [value], and returns the resulting [ByteArray].
|
||||
*/
|
||||
fun base64Decode(value: String): ByteArray
|
||||
|
||||
/**
|
||||
* Encodes the specified [byteArray], and returns the encoded String.
|
||||
*/
|
||||
fun base32Encode(byteArray: ByteArray): String
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.bitwarden.authenticator.data.platform.manager
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.common.io.BaseEncoding
|
||||
|
||||
/**
|
||||
* Default implementation of [BitwardenEncodingManager].
|
||||
*/
|
||||
class BitwardenEncodingManagerImpl : BitwardenEncodingManager {
|
||||
override fun uriDecode(value: String): String = Uri.decode(value)
|
||||
|
||||
override fun base64Decode(value: String): ByteArray = BaseEncoding.base64().decode(value)
|
||||
|
||||
override fun base32Encode(byteArray: ByteArray): String =
|
||||
BaseEncoding.base32().encode(byteArray)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user